Answers to Selected Exercises

Worked solutions to the daggered (†) and odd-numbered exercises from each chapter. Try every problem before reading its solution.

Chapter 1 — Why Fortran?

Solutions to the daggered (†) and odd-numbered problems. Research and setup problems (1.11, 1.13, 1.25, 1.27) have answers that depend on when and where you look, so a model response is given.

1.1 (a) Arrays are first-class objects — the language knows an array's shape and bounds, and whole-array operations expose that structure to the compiler. (b) Procedure arguments are assumed not to alias — the compiler may assume distinct arguments occupy distinct memory, which licenses aggressive reordering and vectorization.

1.3 The lowercase "Fortran" (from the 1990 standard onward) marks the modern language — free-form, arrays, modules, dynamic memory — while uppercase "FORTRAN" (77 and earlier) marks the fixed-form, implicit-typing, COMMON/GOTO era. The divide is Fortran 90.

1.5 False. Every TOP500 machine runs Linux, written in C; the operating system is not Fortran. What is true is that much of the scientific application work on those machines runs in Fortran.

1.7 FORTRAN 77 — structured IF/THEN/ELSE and a real CHARACTER type. Fortran 90 — free-form source, array operations, modules, and dynamic (allocatable) memory. Fortran 2003 — object orientation and standardized C interoperability. Fortran 2008 — coarrays (built-in parallelism) and submodules.

1.9 Because for thirteen years (1978–1991) the language really was frozen in its worst, FORTRAN-77 form while C and C++ advanced rapidly. People who formed their opinion during that gap — or who only ever saw FORTRAN 77 code — reasonably concluded the language was obsolete, and the reputation outlived the stagnation that produced it.

1.11 (model) Example: WRF is a large community weather model of well over half a million lines, predominantly Fortran, that solves the equations of atmospheric fluid dynamics on a 3-D grid to produce research and operational forecasts. (Any well-documented Fortran code from your field is a valid answer; report its rough size and the problem it solves.)

1.13 (model) The number-one TOP500 machine runs Linux, which is written in C — so the operating system is not Fortran. The honest reconciliation: "Fortran runs the supercomputers" should be read as "a large share of the scientific computation those machines perform is done in Fortran," not "the system software is Fortran."

1.15 (model) "Fair points — for a website or a quick script, Python is absolutely the right choice, and Fortran would be the wrong one. But the reason weather forecasts, climate models, and reactor simulations are still written in Fortran isn't nostalgia; it's that for dense numerical computation on big arrays, Fortran's design lets the compiler produce some of the fastest code available, and the ecosystem (LAPACK, coarrays, MPI) is built for exactly that work. Different tools for different jobs — and this is Fortran's job."

1.17 (argues for the ethic) They should modernize, not rewrite. The 100,000 lines are valuable not because they are elegant but because they are validated — trusted against decades of data. A rewrite discards that validation and years of encoded, often undocumented physics, at high risk and high cost, to gain clean code that modernization would also produce. The intern's energy is better spent adding tests and incrementally modernizing (see Chapter 18).

1.18 Total $\approx 10^{8}\ \text{cells} \times 10^{3}\ \text{flop/cell/step} \times 10^{5}\ \text{steps} = 10^{16}$ operations. On one core at $10^{10}$ ops/s that is $10^{6}$ s $\approx 11.6$ core-days. No forecast can wait eleven days, so the work must be split across thousands of cores to finish in about an hour — which is the whole motivation for Part VIII. (See code/exercise-solutions.f90.)

1.19 Fortran run time $= 10/50 = 0.2$ h. You save $9.8$ h per run; the 4-hour porting cost is repaid after $4/9.8 \approx 0.41$ runs — i.e., it pays for itself on the very first run.

1.20 (model) A parameter sweep of 20 simulations, each taking 3 days at the current speed, totals 60 days — past a conference deadline. A 2× speedup makes each run 1.5 days, 30 days total — in under the deadline. The factor of two is literally the difference between submitting and not.

1.21 Wrong: Fortran is a high-level language, not low-level. It is fast because the compiler can optimize its high-level array constructs freely (thanks to first-class arrays and no aliasing), not because the programmer writes near-assembly. High-level and fast are not opposites here.

1.23 Misleading: NumPy's array operations are compiled C and Fortran, so "NumPy is fast" is really "compiled code is fast." When a computation cannot be expressed as a few big NumPy calls — an element-by-element loop with dependencies, as real simulations are — pure Python is often 50–100× slower, and Fortran is exactly what you reach for.

1.25 (model) heat-solver/README.md: "This project simulates heat spreading through a square metal plate. The plate's edges are held at fixed temperatures (one hot, three cold); I want to compute how the interior temperature evolves over time and observe it settle to a steady state." (Any clear 2–3 sentence problem statement for heat, fluid flow, or an N-body system is correct.)

1.27 (model) Running gfortran --version prints a version banner (e.g., GNU Fortran (…) 13.x) if a compiler is installed, or a "command not found" error if not. Either outcome is a correct report; if it is missing, Chapter 2 installs it.


Chapter 2 — Setting Up

Solutions to the daggered (†) and odd-numbered problems. The machine-dependent setup problem (2.1) and the build problem (2.28) have answers that depend on your system, so a model response is given. Worked code for the computational problems is in code/exercise-solutions.f90.

2.1 (model) Running gfortran --version prints a version banner ending in a version number (e.g. GNU Fortran (…) 13.2.0) if a compiler is installed, or gfortran: command not found if it is not. You want version 10 or newer. If it is missing, §2.1 (Installing gfortran) gives the one-line install for your operating system (apt/dnf/pacman on Linux, Homebrew on macOS, MSYS2 or WSL on Windows).

2.3 The three stages, in order: (1) compile — the compiler translates each source file into an object file (machine code with references not yet resolved); (2) link — the linker combines the object file(s) with the libraries they need into a complete executable, resolving every reference; (3) run — the operating system loads and executes the executable, producing output.

2.5 A compiler translates human-readable source into machine instructions, producing an object file (e.g. turning hello.f90 into hello.o). A linker combines object files and libraries into a runnable executable, resolving references (e.g. connecting your print to the runtime library's implementation of it, and writing the file hello). Compiling is per-source-file translation; linking is whole-program assembly.

2.7 Both lines print flush-left (the a edit descriptor adds no leading blank), in order:

line one
line two

2.9 7.0_dp / 2.0_dp is real division, equal to 3.5; printed with f0.2 it is 3.50, so the program prints seven halves = 3.50. (Contrast: with integers, 7 / 2 would be 3 — the integer-division trap of Chapter 3 — but these operands are reals.)

2.11 (port it) A faithful translation:

program temp_range
  use, intrinsic :: iso_fortran_env, only: dp => real64
  implicit none
  real(dp), parameter :: hot = 100.0_dp, cold = 0.0_dp
  print '(a, f0.2)', 'range = ', hot - cold
end program temp_range

prints range = 100.00. The Python printed range = 100.0; both agree numerically (the range is 100). The Fortran costs you type declarations and a compile step that Python does not — a poor trade for four lines, an excellent one once the computation is large (§2.7's Python comparison).

2.13 A linker error. The compile stage succeeded (an object file was produced), but the linker could not find the definition of compute_flux. Two plausible causes: (a) the routine's name is misspelled, or its source file was left out of the build so its object file was never produced or passed to the linker; (b) it lives in a library that was not linked (a missing -l… flag). The trailing underscore in compute_flux_ is the compiler's name-mangling of the symbol — a detail you meet again in Chapter 14; for now, read it as the symbol's name.

2.14 (†) A compile-stage error. With implicit none in force, tempreature was used but never declared — almost certainly a typo for temperature — and the compiler refuses to invent a type for it. The fix: correct the spelling (and make sure the intended variable is declared). This is precisely the class of bug implicit none exists to catch (§2.6); without it, the misspelling would have become a silent, zero-valued variable instead of an error.

2.15 With separate compilation, changing one file among two hundred means recompiling only that file (one new object file) and then relinking — seconds of work. Without it, every one-line edit would force a full recompile of all two hundred files, turning each iteration into minutes of waiting and making development impractical. This is a central reason Fortran programs are organized into modules (Chapter 8).

2.16 (†) -std=f2018(b) hold the code to the 2018 standard; -Wall(d) enable helpful warnings; -O2(e) optimize the generated code for speed; -g(c) embed source-level debug information; -fcheck=all(a) insert run-time checks such as array bounds.

2.17 Development: gfortran -std=f2018 -Wall -g -fcheck=all sim.f90 -o sim. Release: gfortran -std=f2018 -Wall -O2 sim.f90 -o sim. -fcheck=all belongs in the development build because its run-time checks catch bugs (out-of-bounds accesses, etc.) early, at the cost of speed; the release build drops them so the program runs at full speed. Never quote a timing measured with -fcheck=all.

2.18 (†) The loop was built with -fcheck=all (and, since no -O was given, at the default -O0), so it carries a run-time check on every array access and receives no optimization at all — a debugging build, not a representative one. Before quoting any timing, rebuild with the release profile (-O2, checks off) and measure that. The "4 seconds" is largely measuring the checks and the missing optimization, not the algorithm.

2.19 No. A warning is not an error: a warning does not stop the build (you still get an executable); an error does (you get none). Recommended policy: treat warnings as errors-in-waiting and eliminate every one, because -Wall flags genuine mistakes (unused variables, suspicious conversions, uninitialized values) often enough to be worth heeding. Ignoring warnings is declining free code review.

2.20 (†) It prints force = followed by a value of 0 (in some list-directed representation of 0.0). Two things go wrong, both silent, because there is no implicit none. First, frce (line 4) is a typo for force; the product mass * acceleration is stored into the new variable frce and then thrown away, while the print reads force, which was never assigned and so holds 0. Second — subtler — mass begins with m, which falls in the in range, so implicit typing makes it an integer; mass = 10.0 therefore truncates to 10. Add implicit none and declare mass, acceleration, and force as real(dp), and the compiler immediately flags both frce and the undeclared force. A program that runs, prints a plausible number, and is silently wrong is exactly the disaster §2.6 exists to prevent.

2.21 (†) The program opens as program mismatch but closes as end program goodbye, and the two names must match. gfortran reports a compile-stage error (wording along the lines of Expecting END PROGRAM statement for 'mismatch') and produces no executable. The fix is to make the closing name match: end program mismatch. (Same class of error as Case Study 1, Phase 2.)

2.23 (†) First hypothesis, before reading any message: the file is fixed-form FORTRAN 77 saved with a .f90 extension, so gfortran reads it as free-form and chokes on the column layout, the C-in-column-one comments, and the column-six continuations. The general fix is to convert it to free-form, not merely rename it (§2.5) — the modernization discipline of Part IV. As a stopgap merely to compile it as-is, rename it to .f (or pass gfortran's fixed-form flag) so the compiler reads it with the right conventions.

2.25 (†) Two language features must both be present for the bug to slip through. (1) Insignificant blanks in fixed-form: to the fixed-form lexer, DO 5 I = 1.100 and DO5I = 1.100 are identical, so a mistyped loop and an assignment are indistinguishable. (2) Implicit typing: the accidental target DO5I needs no declaration, so it is silently accepted as a new real variable. Free-form (where blanks are significant, so DO 5 I and DO5I differ) neutralizes the first; implicit none (which requires DO5I to be declared) neutralizes the second. Either modern feature alone breaks the bug; modern Fortran gives you both.

2.26 (†) By the implicit-typing rule (in → integer, everything else → real):

Name First letter Implicit type
k_max k integer
flux f real
n_steps n integer
temperature t real
Rho R real
mass m integer

The traps are k_max and mass: both begin with letters in the in range and are therefore integers, which is rarely what a name like mass intends. implicit none removes the guessing entirely.

2.27 (†) (a) 150 rebuilds × 3 s = 450 s = 7.5 minutes/day waiting. (b) 150 × 40 s = 6000 s = 100 minutes/day — over an hour and a half of pure waiting, and growing with the project. This is why separate compilation (Chapter 8) matters: by recompiling only the file you changed and relinking, a large project stays near the 3-second regime instead of sliding toward the 40-second one, because you never recompile the files you did not touch. (Worked in code/exercise-solutions.f90.)


Chapter 3 — Variables, Types, and Arithmetic

Solutions to the daggered (†) and odd-numbered problems. The compilable numeric solutions (3.13, 3.16, 3.18, 3.19, and the quadratic) are in code/exercise-solutions.f90; the estimation problems give a worked order-of-magnitude answer rather than a single exact number.

3.1 The six intrinsic types, with an example quantity each: integer — the number of grid points along a plate edge; real (real(dp)) — the temperature at a point; double precision — a physical constant to 15 digits (the legacy keyword for the same thing); complex — a Fourier coefficient or wave amplitude $a + b\,i$; logical — a "has the simulation converged?" flag; character — an output-file label.

3.3 Plain real is single precision, about seven significant decimal digits. In a long simulation — millions of timesteps, each accumulating a little rounding error — seven digits can be eroded until the answer is wrong in a significant figure that matters. real(dp) gives about fifteen digits, a large safety margin, and stating the precision with selected_real_kind also makes the code reproduce on other machines. The rule: never rely on default real precision in numerical code.

3.5 precision(1.0) returns 6 and precision(1.0_dp) returns 15 on a typical compiler (single vs. double precision). Practical consequence: a computation carried in default real has only ~6–7 trustworthy digits, so results should not be quoted or compared beyond that; real(dp) roughly doubles the trustworthy digits, which is why it is the default for numerical work.

3.6 Integer division and remainder:

q = 3
r = 2

17 / 5 truncates 3.4 toward zero to 3; mod(17, 5) is the remainder 2.

3.7 22.0_dp / 7.0_dp = 3.142857142857...; f8.4 rounds to four decimals (3.1429, rounding up at the fourth place) and right-justifies in a field of width 8:

  3.1429

3.8 The two remainder functions on negative arguments:

a = -2
b = 1
c = 2

mod(-8, 3) = -2 (sign of the dividend -8); modulo(-8, 3) = 1 (sign of the divisor 3, result in $[0,3)$); mod(8, -3) = 2 (sign of the dividend 8).

3.9 2.0_dp**0.5_dp is $\sqrt{2} = 1.41421356\ldots$ and abs(-6.25_dp) is 6.25, each in f8.3:

x =    1.414
y =    6.250

3.10 The first assignment is the trap:

p (from 3/2)     =  1.000
p (from 3.0/2.0) =  1.500

3 / 2 is integer division → 1, widened to 1.0; 3.0_dp / 2.0_dp is real division → 1.5. The real(dp) type of p cannot rescue the integer division on the right-hand side.

3.11 (10 + 15 + 21) is 46, and 46 / 3 is integer division → 15 (the true 15.333… truncated), then widened to 15.0; avg is wrong. Fix by converting before dividing: avg = real(10 + 15 + 21, dp) / 3.0_dp, which gives 15.333…. (For a genuinely integer sum, sum with integers but divide in real.)

3.13 sin(30.0_dp) takes the sine of 30 radians, not 30 degrees — a nonsense result — because the trig intrinsics expect radians. Convert first: s = sin(30.0_dp * pi / 180.0_dp), i.e. sin(pi/6) = 0.5. See code/exercise-solutions.f90.

3.15 mod(n, 2) is an integer (0 or 1), and assigning an integer to a logical is a type mismatch that does not compile. Fix with a comparison, which yields a logical: even = (mod(n, 2) == 0). For n = 7, mod(7, 2) = 1, so even is .false..

3.16 Fahrenheit → Celsius, done correctly: c = 5.0_dp / 9.0_dp * (f - 32.0_dp). For f = 98.6_dp, c = 37.0. The buggy 5 / 9 would be integer division (0), zeroing every result. Prints 37.000; see code/exercise-solutions.f90.

3.17 Python's -7 // 2 floors to -4; Fortran's integer -7 / 2 truncates toward zero to -3. So print '(i0)', -7 / 2 prints -3, which differs from Python. To reproduce Python's floor in Fortran, use floor(-7.0_dp / 2.0_dp), which is floor(-3.5) = -4.

3.18 Euclidean distance $\sqrt{(4-1)^2 + (6-2)^2} = \sqrt{9 + 16} = \sqrt{25} = 5$: d = sqrt((4.0_dp - 1.0_dp)**2 + (6.0_dp - 2.0_dp)**2). Prints 5.000; see code/exercise-solutions.f90.

3.19 hypot(3.0_dp, 4.0_dp) is the same 3–4–5 right triangle: 5.0. hypot computes $\sqrt{x^2 + y^2}$ while guarding against overflow in the squaring. Prints 5.000.

3.20 A 1000 × 1000 array is $10^{6}$ elements. In real(dp) (8 bytes each): $8 \times 10^{6}$ bytes = 8 MB. In single precision (4 bytes each): $4 \times 10^{6}$ bytes = 4 MB. This is the memory half of the precision trade-off from the §3.2 Performance Note: double precision doubles the footprint (and the bytes a memory-bound loop must move).

3.21 No — a default 32-bit integer maxes near $2.1 \times 10^{9}$, and $5 \times 10^{9}$ overflows it (silently wrapping to a negative number). The count has 10 decimal digits, so request integer, parameter :: big = selected_int_kind(10); that yields a 64-bit integer kind, with a range of about $\pm 9.2 \times 10^{18}$ — ample. (selected_int_kind(18) asks for the same 64-bit type more explicitly.)

3.22 Under the crude "one digit lost per $10^{6}$ operations" model: single precision (~7 digits) has essentially no correct digits left after about $7 \times 10^{6}$ operations; double (~15 digits) after about $1.5 \times 10^{7}$. The number that matters is the margin: double carries roughly eight more correct digits throughout, so long after single would be worthless, double still has plenty of headroom — and real error growth is usually far slower than linear (often like $\sqrt{N}$), so the margin buys much more safety than the raw ratio suggests. Hence real(dp) as the default for any long run. (This linear model is illustrative and pessimistic; the rigorous treatment is Chapter 20.)

3.23 Add real(dp), parameter :: t_init = 20.0_dp and real(dp), parameter :: t_hot = 100.0_dp, printed with f8.3:

t_init (C) =   20.000
t_hot  (C) =  100.000

3.25 t_diffuse = length**2 / alpha = 1.0 / 1.0e-4 = 1.0e4, in es10.3:

t_diffuse (s) =  1.000E+04

It says the simulation must advance about $10^{4}$ seconds of simulated time for heat to diffuse across the plate and approach steady state — which, with a small stable timestep, means a great many steps (the stability reasoning is Chapter 24).

3.26 gfortran -std=f2018 -Wall -fcheck=all checkpoint.f90 -o run (add -g if you plan to use a debugger). For a production build, remove -fcheck=all: the run-time bounds/other checks it inserts cost performance and are a development aid, not something you ship. (You would typically also raise optimization, e.g. -O2, for production.)

3.27 The loop is memory-bandwidth-bound: the processor spends its time waiting for numbers to arrive from memory, not doing arithmetic. Halving the bytes per number (single vs. double) halves the data that must be moved, so a bandwidth-limited loop runs nearly twice as fast; a SIMD vector register also holds twice as many singles, doubling the arithmetic per instruction. This is Chapter 1's "performance is not accidental" — the speedup comes from understanding the hardware (memory layout and bandwidth), not from luck.

3.28 With implicit none, mistyping alpha as alhpa is an undeclared name → a compile error, caught immediately and pointing at the exact line. Without implicit none, alhpa is silently given an implicit type (a real, since it starts with a), initialized to nothing meaningful, and the program computes with a garbage value and no error at all — a silent wrong answer. The compile error is vastly preferable; this is exactly why implicit none (Chapter 2) is non-negotiable.


Chapter 4 — Control Flow

Solutions to the daggered (†) and odd-numbered problems. Computational answers (4.7, 4.9, 4.18, 4.26) also ship as compilable code in code/exercise-solutions.f90.

4.1 n = 7: the first test n < 0 is false, n == 0 is false, n < 10 is true → the loop prints small positive. Only the first true branch runs; the final else is skipped.

4.3 A select case matches an exact, discrete label, and real values are neither. (1) Not exact: values like 0.1 are inexact in binary, so "does this real equal the label?" is unreliable. (2) Not discrete: there is no "next" real after 3.0, so enumerating or ranging over labels is ill-defined. The standard therefore restricts the case expression to integer, character, or logical.

4.5 exit terminates the whole loop and jumps past its end do; cycle ends only the current iteration and proceeds to the next. A bare exit acts on the innermost enclosing loop; exit outer acts on the loop named outer (leaving it and any loops nested inside it).

4.7 Test the tightest condition first — a multiple of 15 is also a multiple of 3 and of 5, so if you tested mod(i,3) first, 15 would wrongly print Fizz. Output (see code/exercise-solutions.f90): 1, 2, Fizz, 4, Buzz, Fizz, 7, 8, Fizz, Buzz, 11, Fizz, 13, 14, FizzBuzz, one per line.

4.9 do while (n /= 1) with the even/odd rule: 6 → 3 → 10 → 5 → 16 → 8 → 4 → 2 → 1, which is 8 steps. n/2 is exact integer division for even n.

4.11 Port with do while:

program port_411
  implicit none
  integer :: total, i
  total = 0
  i = 1
  do while (total < 100)
    total = total + i
    i = i + 1
  end do
  print '(i0, 1x, i0)', i - 1, total     ! prints: 14 105
end program port_411

The loop keeps adding until total first reaches or passes 100: the triangular numbers climb 1, 3, 6, …, 91 (at i=14), then +14 → 105, at which point total < 100 is false. It prints i-1 = 14 and total = 105. The difference to confront: Python's += and while cond: map to explicit total = total + i and do while (cond), and both languages leave the counter one past the last value used — hence i - 1.

4.13 C's missing break after case 'A': makes 'A' fall through into 'B', so both print pass with merit. Fortran has no fall-through, so you reproduce the intent by putting both labels in one case list:

select case (grade)
case ('A', 'B')
  print '(a)', 'pass with merit'
case ('C')
  print '(a)', 'pass'
case default
  print '(a)', 'see instructor'
end select

The lesson: in C, stacked empty cases are the fall-through idiom; in Fortran the same idea is an explicit label list, which is harder to get wrong.

4.14 0.1 is not exact in binary, so adding it ten times gives 0.9999999999999999, not 1.0. The test x == 1.0_dp is therefore never true, and the loop runs forever (x sails past 1.0 without ever equalling it). Fix by counting with an integer:

integer  :: k
real(dp) :: x
do k = 1, 10
  x = real(k, dp) * 0.1_dp
end do

or, if you must accumulate, exit on if (x >= 1.0_dp) exit. Never gate a loop on exact real equality.

4.15 A bare exit leaves only the inner col loop, so the outer row loop continues — the table keeps being scanned. To stop the whole scan at the first zero, name the outer loop and exit it:

scan: do row = 1, nrows
  do col = 1, ncols
    if (a(col, row) == 0) exit scan
  end do
end do scan

4.16 The labels case (0:60) and case (60:100) overlap at 60, and select case requires disjoint labels (a value could otherwise match two cases), so it is a compile-time error. Fix by making them disjoint, e.g. case (0:59) / case (60:100) (choosing which side 60 belongs to).

4.17 Iteration i reads a(i-1), which iteration i-1 writes — a loop-carried dependency, so the iterations are not independent and do concurrent's "any order" promise is false; a parallel run gives wrong or nondeterministic results. This prefix-sum is inherently sequential in this form; write it as an ordinary do:

do i = 2, n
  a(i) = a(i) + a(i-1)
end do

4.18 See code/exercise-solutions.f90. Looping bc_type over 0–3 exercises all four branches: bc=0 : UNKNOWN, bc=1 : Dirichlet …, bc=2 : Neumann …, bc=3 : periodic.

4.19 With n_steps = 9, mod(step, 3) == 0 is true at steps 3, 6, 9, so those three report. Wrap the print in if (mod(step,3) == 0) then … end if. (Using if (mod(step,3) /= 0) cycle before the print also works, but only if there is no other per-step work after the print, since cycle would skip that too — the guarding if is the safer general pattern.)

4.20 Add a flag and a named exit:

logical :: converged
converged = .false.
time_loop: do step = 1, max_steps
  ! … per-step work …
  if (step >= n_settle) converged = .true.
  if (converged) exit time_loop
end do time_loop
print '(a, i0, a)', 'ran ', min(step, max_steps), ' steps'

n_settle stands in for the real convergence test of Chapter 24. (After a normal do completes, step is one past the last value; here we exit early, so step holds the exit value. min guards the printed count if the loop ends by hitting the cap.)

4.21 Interior points per step $\approx n^2 = (1000)^2 = 10^6$; over $10^5$ steps that is $10^{11}$ updates. At $10^{9}$ updates/second on one core, that is $10^{11}/10^{9} = 100$ seconds per run. One hundred seconds for a single run — and a study needs many runs, at higher resolution — is why you spread the grid sweep across cores in Part VIII.

4.23 Halving from 1.0 needs $2^{-k} < 10^{-6}$, i.e. $2^{k} > 10^{6}$, i.e. $k > \log_2(10^6) = 6\log_2 10 \approx 6 \times 3.32 \approx 19.9$, so about 20 iterations. (Halving is exponential, so the count grows only logarithmically in the tolerance — tightening tol by another factor of a million costs just ~20 more passes.)

4.24 r = 1 / i computes 1 / i in integer arithmetic first (integer division), then assigns to the real r. For i = 1 that is 1; for i ≥ 2 it truncates to 0. So it prints 1.000, 0.000, 0.000, 0.000, 0.000. Fix by forcing real division:

r = 1.0_dp / real(i, dp)     ! -> 1.000, 0.500, 0.333, 0.250, 0.200

4.25 -fcheck=all (which includes -fcheck=bounds) makes an out-of-bounds array access abort at run time with a message naming the array and index, instead of silently reading or corrupting neighboring memory. You compile with it during development because it turns a silent, hard-to-find bug into a loud, located one; you drop it for production runs because the per-access checks cost measurable runtime — the classic debug-vs- release trade you meet again in Chapter 30.

4.26 See code/exercise-solutions.f90. Expression: leap = (mod(y,4)==0 .and. mod(y,100)/=0) .or. (mod(y,400)==0). Precedence is .not. > .and. > .or., so .and. binds tighter than .or. and the parentheses around the .and. group are documentation, not necessity. By hand: 1900 → div by 4 but also by 100, not by 400 → (T.and.F).or.F = F; 2000 → div by 400 → T; 2024 → div by 4, not by 100 → T; 2023 → not div by 4 → F.

4.27 n / 2 is integer division, so n/2 > 0.5 promotes the integer n/2 to real for the comparison: n/2 is 0 for n ≤ 1 and ≥ 1 for n ≥ 2, so the condition is true exactly when n ≥ 2, not n ≥ 1. If the author meant "is n at least 1?", write if (n >= 1). The original silently tests a different threshold — a classic integer-division-plus-mixed-mode trap.

4.28 A 5×5 grid has 25 points; interior points are i ∈ {2,3,4} and j ∈ {2,3,4}, a 3×3 block = 9 interior, hence 16 boundary. Each of the 3 steps prints step k: boundary=16 interior=9.


Chapter 5 — Arrays

Solutions to the daggered (†) and odd-numbered problems. The computational ones are also worked as runnable code in code/exercise-solutions.f90; design problems 5.20–5.21 give a model answer that admits variations.

5.1 (a) v(10): rank 1, shape [10], size 10. (b) g(5,5): rank 2, shape [5, 5], size 25. (c) s(-3:3): rank 1, shape [7] (indices −3 through 3 are seven values), size 7. (d) cube(4,4,4): rank 3, shape [4, 4, 4], size 64.

5.3 a(1) = 10, a(4) = 40, size(a) = 4, sum(a) = 100. a(0) and a(5) are out of bounds — the default lower bound is 1 and the upper is 4 — so both are undefined behavior; compiled with -fcheck=all, the program halts with a bounds error rather than reading stray memory.

5.5 With m(i,j) = 10*i + j: (a) row 3 is m(3, :) = [31, 32, 33, 34]. (b) column 1 is m(:, 1) = [11, 21, 31, 41]. (c) the top-left 2×2 block is m(1:2, 1:2), values [[11, 12], [21, 22]]. (d) every other element of row 2 is m(2, 1:4:2) = [21, 23].

5.7 a + b = [5, 5, 5, 5]; a * b = [4, 6, 6, 4]; sum(a * b) = 20. Output:

   5.0   5.0   5.0   5.0
   4.0   6.0   6.0   4.0
  20.0

(sum(a*b) is the dot product of a and b; dot_product(a, b) gives the same 20.)

5.9 sum = 28, product = 2160, maxval = 9, minval = 1, maxloc(v, dim=1) = 5 (the 9 sits at position 5), count(v > 4) = 3 (the values 5, 8, 9). See ex09_reductions in the code.

5.11 Elementwise A * B = [[2, 0], [5, 18]] (multiply matching positions: 2·1, 0·4, 1·5, 3·6). Matrix matmul(A, B) = [[2, 8], [16, 22]] (C(1,1)=2·1+0·5=2, C(1,2)=2·4+0·6=8, C(2,1)=1·1+3·5=16, C(2,2)=1·4+3·6=22). They differ because * multiplies position-by-position while matmul sums row-times-column products. Use elementwise for scaling or masking a field; use matmul for linear algebra.

5.13 A masked reduction, no loop:

real(dp) :: r(6) = [ -2.0_dp, 4.0_dp, 6.0_dp, -1.0_dp, 8.0_dp, 2.0_dp ]
real(dp) :: mean_pos
mean_pos = sum(r, mask = r > 0.0_dp) / real(count(r > 0.0_dp), dp)

The positive entries are 4, 6, 8, 2; their sum is 20, count 4, so mean_pos = 5.0. See ex13_mean_positive.

5.15 y = 2.0_dp * x + 3.0_dp — one whole-array statement. The Python version is slow because each iteration of the for loop pays the interpreter's per-element overhead (commonly 50–100× the cost of the compiled equivalent); the Fortran statement compiles to a single vectorized loop over contiguous memory.

5.16 It does not compile: a has shape [3] and b has shape [4], so b = a is a non-conformable whole-array assignment, and gfortran rejects it (different shapes for array assignment). Fixes: make the shapes agree, or assign a section — b(1:3) = a (leaving b(4) untouched).

5.17 Two bugs from importing C's zero-based habit. v runs v(1)..v(n), so i = 0 accesses v(0), which is out of bounds, and the loop stops at n-1, never touching v(n). Fix: do i = 1, n. (This is the one-based-indexing pitfall from §5.1; -fcheck=all catches the v(0) access at run time.)

5.18 The answer is correct but the loop order fights the memory layout. The inner loop varies j (the second index), so consecutive accesses a(i, j) jump a full column's length — 1000 elements — through memory, using one value from each cache line and discarding the rest. Because Fortran is column-major, the first index belongs on the inner loop:

do j = 1, 1000
  do i = 1, 1000
    a(i, j) = 0.0_dp
  end do
end do

(Better still for this particular case: a = 0.0_dp.) The reordered version can run several times faster — measured in Chapter 27.

5.19 data is declared allocatable but never allocated, so data(1) = 3.14_dp writes through an unallocated array — illegal, and undefined behavior (trapped by -fcheck=all). Fix: allocate(data(n)) before use, or let allocation-on-assignment do it: data = [3.14_dp, …].

5.20 Append, after the Laplacian:

print '(a, f8.1)', 'max |lap| interior = ', maxval(abs(lap(2:n-1, 2:n-1)))

For the u(i,j) = i**3 field, the interior Laplacian values are 12, 12, 18, 18, so the largest magnitude is 18.0. (A real solver watches a quantity like this shrink toward zero to decide it has reached steady state.)

5.21 Fill cold, then set the hot edge last so the corners are unambiguous:

u = 0.0_dp             ! all edges cold to start
u(n, :) = 0.0_dp       ! bottom  (already 0 here; shown for symmetry)
u(:, 1) = 0.0_dp       ! left
u(:, n) = 0.0_dp       ! right
u(1, :) = 100.0_dp     ! TOP row hot, assigned last -> corners (1,1),(1,n) read 100

The corners belong to two edges at once, so assignment order decides their value; document the choice (hot here). Physically the corners are a measure-zero artifact and either convention is defensible — what matters is that it is deliberate, not accidental.

5.22 $1000 \times 1000 \times 100 = 10^{8}$ cells $\times\ 8$ bytes $= 8.0\times10^{8}$ bytes. In GiB: $8.0\times10^{8} / 1024^3 \approx 0.75$ GiB. It fits comfortably in a 16 GiB laptop; two such fields (you need the current and next step) are about 1.5 GiB, still an easy fit. See ex22_memory.

5.23 $2n^3 = 2 \times (1000)^3 = 2.0\times10^{9}$ operations. At $10^{10}$ ops/s that is about $0.2$ seconds for one $1000\times1000$ matmul. Fine for a one-off; but inside a loop, or at $n = 10{,}000$ (a thousand times more work), this is where a tuned BLAS/LAPACK (Chapter 21) earns its keep. See ex23_matmul_cost.

5.24 sum(k) = 10, and 10 / 2 = 5 by integer division (exact here), so it prints 5. The trap is sharper for a true average: sum(k) / size(k) is 10 / 4 = 2 — integer division truncates the 2.5. The real-valued fix: real(sum(k), dp) / real(size(k), dp) gives 2.5. (The Chapter 3 integer-division trap, now inside array code.)

5.25 For a rank-1 a:

do i = 1, size(a)
  if (a(i) < 0.0_dp) a(i) = 0.0_dp
end do

We prefer where (a < 0.0_dp) a = 0.0_dp: it is shorter, has no index to mismanage, states the mask directly, and hands the compiler the whole-array structure to vectorize. (For a rank-2 array the explicit form needs a nested loop in column-major order — more to get wrong — while the where is unchanged.)

5.26 real(sum(counts), dp) / real(size(counts), dp). With counts = [7, 4, 9, 2, 8], the sum is 30 and the size 5, so the mean is 6.0. Converting to real(dp) before dividing is essential — sum(counts) / size(counts) would truncate.

5.27 A named loop with exit:

idx = 0
search: do i = 1, n
  if (v(i) > thr) then
    idx = i
    exit search
  end if
end do search

The one-call equivalent is findloc(v > thr, .true., dim=1), which returns the index of the first .true. element of the mask (0 if none) — the array-thinking version of the same linear search.

5.28 See ex28_alloc_mean:

integer,  parameter   :: m = 5
real(dp), allocatable :: a(:)
integer :: i
allocate(a(m))
a = [ (real(i, dp), i = 1, m) ]        ! 1.0 .. 5.0
print '(a, f6.2)', 'sum  = ', sum(a)                          ! 15.00
print '(a, f6.2)', 'mean = ', sum(a) / real(size(a), dp)      !  3.00
deallocate(a)

Output: sum = 15.00 and mean = 3.00.


Chapter 6 — Procedures

Solutions to the daggered (†) and odd-numbered problems. The computational ones (6.5, 6.11, 6.14, 6.17, 6.24) are also compilable in code/exercise-solutions.f90. Assume dp => real64 and implicit none.

6.1 (a) function — one value (the mean), no side effects. (b) subroutine — it modifies both arguments in place and returns nothing to assign. (c) function — one value (the area). (d) subroutine — it performs an action (writing a file), returning no value. (e) subroutine is idiomatic: a real solver returns more than the solution x (a status/info flag, sometimes a factorization) and often overwrites A and b. A pure function returning x is technically defensible for a trivial case — an array is one value — but the moment you need status output or in-place work, the subroutine is right.

6.3 triple(2.0) = 6.0, then twice(6.0) = 12.0. Printed with f6.2: 12.00 (one leading space, field width 6). The key point is that a function call nests inside an expression exactly like an intrinsic.

6.5 (†, code) An assumed-shape pure function:

pure function norm2_of(x) result(nrm)
  real(dp), intent(in) :: x(:)
  real(dp) :: nrm
  nrm = sqrt(sum(x**2))
end function norm2_of

norm2_of([3.0_dp, 4.0_dp]) = sqrt(9 + 16) = sqrt(25) = 5.00. (Fortran also has an intrinsic norm2; this reimplements it for practice.)

6.7 (†) minval of [4, -1, 7, 2] is -1; maxval is 7. With '(2f7.2)' the line is -1.00 7.00. Note the subroutine returns two results through its intent(out) arguments — the reason it is a subroutine and not a function.

6.8 (†) It does not compile. v is intent(in) (read-only) but the body assigns to it (v = v / s). gfortran reports something like "Dummy argument 'v' with INTENT(IN) in variable definition context (assignment)". One-word fix: change v's intent to inout — the routine both reads and overwrites the vector.

6.9 The bug: total is intent(out), so it arrives undefined; total = total + x(i) on the first iteration reads that undefined value, giving a garbage (and nondeterministic) sum. The fix, without changing the intent, is to initialize it from scratch — which is exactly what intent(out) expects:

subroutine add_all(x, total)
  real(dp), intent(in)  :: x(:)
  real(dp), intent(out) :: total
  total = 0.0_dp            ! <-- define before use
  total = sum(x)            ! (or keep the loop, now correct)
end subroutine add_all

6.10 (†) intent(inout) and intent(out) arguments must be associated with a definable actual argument — something the procedure can legally assign to. A literal constant like 1.0_dp is not definable (you cannot assign a new value to the number one), so call swap(1.0_dp, 2.0_dp) is rejected at compile time. Variables a and b are definable, so call swap(a, b) is accepted. The compiler has caught a meaningless call for free.

6.11 (†, code) hi optional, guarded with present:

pure function clamp(x, lo, hi) result(y)
  real(dp), intent(in)           :: x, lo
  real(dp), intent(in), optional :: hi
  real(dp) :: y
  y = max(x, lo)
  if (present(hi)) y = min(y, hi)
end function clamp

Results: clamp(5, 0, 3) = min(max(5,0),3) = 3.00; clamp(-1, 0, 3) = min(max(-1,0),3) = 0.00; clamp(2, 0) = max(2,0) = 2.00 (no upper limit applied). A pure function may have an optional argument.

6.13 (†) Dangerous because x = x * factor uses factor unconditionally; if the caller omits it, the procedure references an absent optional argument — undefined behavior. Fix: guard it, supplying a default that means "no scaling":

subroutine scale(x, factor)
  real(dp), intent(inout)        :: x(:)
  real(dp), intent(in), optional :: factor
  real(dp) :: f
  f = 1.0_dp
  if (present(factor)) f = factor
  x = x * f
end subroutine scale

6.14 (†, code) Elemental, so it maps over the array automatically:

elemental function sigmoid(x) result(s)
  real(dp), intent(in) :: x
  real(dp) :: s
  s = 1.0_dp / (1.0_dp + exp(-x))
end function sigmoid

sigmoid([-1.0_dp, 0.0_dp, 1.0_dp]) = [0.2689, 0.5000, 0.7311] (since $1/(1+e^{1})=0.2689$, $1/(1+e^{0})=0.5$, $1/(1+e^{-1})=0.7311$). One statement, whole array, no loop.

6.15 (a) Puresum(x) has no side effects. Not elemental, because its argument is an array, and elemental procedures take scalar arguments. (b) Not pure — it performs I/O (print). (c) Not pure — it modifies module-level state (the counter). (d) Purex**2 + 1 has no side effects; and because its argument and result are scalars, it can also be elemental. So the pure ones are (a) and (d); only (d) can be elemental.

6.16 (†) It can be made pure by removing the side effect — the print:

pure function twice(x) result(y)   ! was: print a debug line, then return 2*x
  real(dp), intent(in) :: x
  real(dp) :: y
  y = 2.0_dp * x
end function twice

A pure procedure cannot do I/O, so the debug output must live elsewhere — in the caller (print the result after the call), or behind a separate, non-pure logging routine. This is the right design anyway: a value function should not be printing.

6.17 (†, code) Euclid's algorithm:

recursive function gcd(a, b) result(g)
  integer, intent(in) :: a, b
  integer :: g
  if (b == 0) then
     g = a
  else
     g = gcd(b, mod(a, b))
  end if
end function gcd

gcd(48, 36): $48 = 1\cdot36 + 12$, $36 = 3\cdot12 + 0 \Rightarrow 12$. gcd(1071, 462): $1071 = 2\cdot462 + 147$, $462 = 3\cdot147 + 21$, $147 = 7\cdot21 + 0 \Rightarrow 21$. Note the mandatory result clause — without it gcd in the body would be ambiguous.

6.19 (†) The hazard is host association: greet has no local i, so do i = 1, 5 writes the host's i — the very variable the outer do i = 1, 3 loop is using. After the first call, i is left at 6, and the outer loop's control is corrupted (it will not iterate as intended). Fix: give greet its own local counter:

subroutine greet()
  integer :: i          ! local i shadows the host's i
  do i = 1, 5
     ! ...
  end do
end subroutine greet

The general lesson: prefer local variables and explicit arguments over leaning on host association.

6.20 (†) Modernized — assumed-shape, real(dp), intent, whole-array op, and the count n disappears (three arguments become two):

subroutine dscale(a, s)
  real(dp), intent(inout) :: a(:)
  real(dp), intent(in)    :: s
  a = a * s
end subroutine dscale

The n argument is gone because an assumed-shape array carries its own extent; a = a * s replaces the explicit loop; and intent/real(dp) add safety and portable precision the original lacked.

6.21 ReLU as an elemental function:

elemental function relu(x) result(y)
  real(dp), intent(in) :: x
  real(dp) :: y
  y = max(x, 0.0_dp)
end function relu

No explicit loop is needed even for a large array: because relu is elemental, writing relu(big_array) applies it to every element, and the compiler generates the (vectorizable) loop for you — the Fortran analog of NumPy applying the operation element-by-element, but compiled.

6.22 (†) Add an optional edge value; when present, set the four boundaries before updating the interior:

subroutine step(field, alpha, dt, bc_value)
  real(dp), intent(inout)        :: field(:,:)
  real(dp), intent(in)           :: alpha, dt
  real(dp), intent(in), optional :: bc_value
  ! ... local old(:,:), i, j, nx, ny ...
  nx = size(field, 1); ny = size(field, 2)
  if (present(bc_value)) then
     field(1,  :) = bc_value
     field(nx, :) = bc_value
     field(:,  1) = bc_value
     field(:, ny) = bc_value
  end if
  ! ... snapshot old = field; update interior as before ...
end subroutine step

optional is right because not every run wants the solver to own the boundaries — some set them once outside the time loop, others manage them elsewhere. Making bc_value mandatory would force every caller to supply an edge temperature even when it is meaningless. (Full program in Case Study 2.)

6.23 (a) One copy of a $1000\times1000$ real(dp) field is $10^6 \times 8 = 8\times10^6$ bytes = 8 MB. (b) Over $10^4$ steps that is $8\times10^6 \times 10^4 = 8\times10^{10}$ bytes ≈ 80 GB of memory traffic just copying snapshots. (c) The cheap fix is to allocate old once and reuse it — pass it in as a caller-owned workspace argument, or keep it in a module — instead of allocate/deallocate-ing it on every one of the 10,000 calls. The copy itself may still be needed for a Jacobi update, but the repeated allocation is pure waste.

6.24 (†, code) maxval on an interior array section:

pure function max_interior(f) result(mx)
  real(dp), intent(in) :: f(:,:)
  real(dp) :: mx
  integer  :: nx, ny
  nx = size(f, 1); ny = size(f, 2)
  mx = maxval(f(2:nx-1, 2:ny-1))
end function max_interior

For the $4\times4$ matrix holding 1.0_dp..16.0_dp in column-major order, the columns are $(1,2,3,4),(5,6,7,8),(9,10,11,12),(13,14,15,16)$; the interior f(2:3, 2:3) is $\{6, 7, 10, 11\}$, whose maximum is 11.00.

6.25 field must be intent(inout). If it were intent(out), the whole array would arrive undefined and the interior values you want to keep would be destroyed on entry — you only mean to overwrite the edges. With inout the interior survives and you reset just the four boundaries:

subroutine apply_dirichlet(field, edge)
  real(dp), intent(inout) :: field(:,:)
  real(dp), intent(in)    :: edge
  integer :: nx, ny
  nx = size(field, 1); ny = size(field, 2)
  field(1,  :) = edge
  field(nx, :) = edge
  field(:,  1) = edge
  field(:, ny) = edge
end subroutine apply_dirichlet

6.26 (†) The missing ingredient is an explicit interface. An external procedure (in its own file, not in a module or contains) with an assumed-shape dummy x(:) needs the caller to see its interface, so the array's shape descriptor is passed correctly; without it, the call is non-conforming and the data arrives garbled. Two clean fixes: (1) put the procedure in a module and use it (Ch. 8) — the preferred solution; or (2) put it in the caller's contains (make it internal). A third, older option is to supply an explicit interface block, but modules make that unnecessary.

6.27 Split into two pure functions and rebuild describe on them:

pure function mean(x) result(m)
  real(dp), intent(in) :: x(:)
  real(dp) :: m
  m = sum(x) / real(size(x), dp)
end function mean

pure function variance(x) result(v)
  real(dp), intent(in) :: x(:)
  real(dp) :: v
  v = sum((x - mean(x))**2) / real(size(x), dp)
end function variance

subroutine describe(x, avg, var)
  real(dp), intent(in)  :: x(:)
  real(dp), intent(out) :: avg, var
  avg = mean(x)
  var = variance(x)
end subroutine describe

Gained: mean and variance are independently testable, reusable, and pure. Lost (mildly): variance recomputes the mean internally, so describe now walks the array a couple of extra times; for large arrays you might pass the precomputed mean into variance to avoid the repeat. For clarity at modest sizes, the split is worth it.

6.28 (†) Peeling one element per call gives a recursion depth of $10^6$ — a million stack frames live at once. Even a lean frame of a few dozen bytes puts the total at tens of megabytes (e.g., $10^6 \times 64\ \text{B} \approx 64\ \text{MB}$), which blows past a typical 8 MB stack and overflows — the program crashes. A do loop uses O(1) stack (one frame, reused every iteration) and is faster besides, having no per-call overhead. Summation is not tree-shaped — the subproblem shrinks by just one element — so recursion is the wrong tool; use a loop (or the sum intrinsic).


Chapter 7 — I/O: Reading Data, Writing Results, and Formatted Output

Solutions to the daggered (†) and odd-numbered problems. Formatted-output answers show every space as a dot (·). Computational answers (7.7, 7.20, 7.21, 7.22, 7.26) are also runnable in code/exercise-solutions.f90, each with a hand-computed expected output. Design problems (7.17–7.19) give a model solution.

7.1 n·=·7 — the string 'n = ' prints as-is (with its trailing space), then i0 prints 7 in minimal width. No leading space (formatted output).

7.3 ··3.14f6.2 rounds 3.14159 to two decimals (3.14, four characters) and right-justifies it in a six-character field, giving two leading spaces.

7.5 A···Ba prints A, 3x inserts three spaces (consuming no value), a prints B.

7.7 Write 1–5 with i0, then read back in an end-of-file loop and sum. The sum is 15.

integer :: u, ios, k, val, total
open(newunit=u, file='nums.txt', status='replace', action='write')
do k = 1, 5
   write(u, '(i0)') k
end do
close(u)
open(newunit=u, file='nums.txt', status='old', action='read')
total = 0
do
   read(u, *, iostat=ios) val
   if (ios == iostat_end) exit
   if (ios /= 0) exit
   total = total + val
end do
close(u)
print '(a, i0)', 'sum = ', total     ! sum = 15

(iostat_end comes from iso_fortran_env.)

7.9 Replace the three-line write with write(u, '(3f8.2)') out and the loop of reads with a single read(u, *) back. The round trip still works. Why list-directed input does not care about line breaks: list-directed input treats blanks, commas, and record boundaries (newlines) all as value separators, so one read(u, *) back reads three values whether they sit on one line or three — the number of reads is determined by the I/O list (three variables), not by the file's line structure.

7.11 12.5_dp formatted to two decimals is 12.50 — five characters — which does not fit the four-wide f4.2 field, so it overflows to ****. The smallest field that fits is f5.2 (exactly 12.50); in practice use f6.2 so a value with a sign or an extra digit still fits with a leading space.

7.13 Two problems. If the first file is still connected, the second open(10, ...) tries to reuse a unit that is already connected — an error. Even if the first was closed, unit 10 is a magic number silently shared by two distant parts of the code, so a change in one place can collide with the other. The fix is open(newunit=u, ...), which asks the runtime for a guaranteed-unused unit and removes the whole class of bug — you never name a unit number yourself.

7.15 The Python sums one number per line; the Fortran is the end-of-file read loop over reals:

real(dp) :: x, total
integer  :: u, ios
open(newunit=u, file='data.txt', status='old', action='read')
total = 0.0_dp
do
   read(u, *, iostat=ios) x
   if (ios == iostat_end) exit
   if (ios /= 0) exit
   total = total + x
end do
close(u)
print '(a, f0.2)', 'sum = ', total

Structurally identical to Python's sum(float(line) for line in f), with the explicit end-of-file test doing what Python's iterator does implicitly. f0.2 matches Python's :.2f.

7.17 (model) Two groups, separating concerns:

&config
  nx = 200, ny = 200, alpha = 1.0e-4, dt = 0.25, n_steps = 5000,
/
&output
  out_file = 'run',
  write_every = 100,
/
character(len=64) :: out_file
integer  :: write_every
namelist /config/ nx, ny, alpha, dt, n_steps
namelist /output/ out_file, write_every
! ... defaults, then ...
read(u, nml=config)
read(u, nml=output)

Two groups beat one because they separate concerns: someone tuning the output cadence never scrolls through the physics, adding an output option does not force every physics-only file to change, and the split mirrors how the code will divide into modules (Ch. 8).

7.19 (model) Stream-binary field writer:

subroutine write_field_binary(f, filename)
  real(dp),         intent(in) :: f(:,:)
  character(len=*), intent(in) :: filename
  integer :: u
  open(newunit=u, file=filename, access='stream', form='unformatted', &
       status='replace', action='write')
  write(u) f
  close(u)
end subroutine

A Python reader: field = numpy.fromfile('field.raw', dtype='float64').reshape((nx, ny), order='F'). The three things the two sides must agree on, none of which the raw file records, are: the dtype (float64real(dp); a real32/float32 mismatch yields garbage), the shape (nx, ny), and the memory order (Fortran is column-major, so order='F'; the wrong order transposes the plate). Byte order (endianness) is a fourth if the file crosses machine architectures. These gaps are exactly why Ch. 25 uses self-describing HDF5/NetCDF.

7.20 (a) $2000 \times 2000 = 4\times10^6$ values $\times\,8$ bytes $= 3.2\times10^7$ bytes $=$ 32 MB. (b) At ~24 bytes/value as text, $4\times10^6 \times 24 = 9.6\times10^7$ bytes $=$ 96 MB. (c) Ratio $96/32 =$ 3.0 — text is about three times larger.

7.21 Text: $4\times10^6 \times 100\,\text{ns} = 0.4$ s per step; binary: $4\times10^6 \times 1\,\text{ns} = 0.004$ s per step. Over 1000 steps: text ≈ 400 s, binary ≈ 4 s. A naive solver that writes text every step spends roughly 100× more time in output than the same solver writing binary — it is I/O-bound for no good reason, which is the lesson of Case Study 1. (Illustrative orders of magnitude.)

7.22 1/3 is integer division: both operands are integers, so the result is the integer 0, and real(0, dp) is 0.0f8.2 prints ····0.00. To get 0.33, make (at least) one operand real so the division is real: change 1/3 to 1.0_dp/3.0_dp (or real(1, dp)/3.0_dp), which is 0.3333…····0.33. This is the §3.4 integer-division trap surfacing inside an I/O statement.

7.23 No. write_field performs I/O (it writes a file), and a pure procedure may not perform I/O or touch any state outside its own outputs — the compiler rejects a write/print inside a pure procedure. Purity is a promise of "no side effects," and writing to disk is precisely a side effect. (pure is for clean computation, per §6.4; I/O routines are inherently impure.)

7.24 field(:, 1) — a whole columnis contiguous. Column-major layout stores the first index fastest, so the elements of a column occupy consecutive memory addresses. field(1, :) — a row — is not contiguous: consecutive row elements are one column apart, i.e. size(field, 1) elements apart in memory. This is the Ch. 5 column-major idea; it is why writing (and looping) down columns is memory-friendly.

7.25 Loop columns outer, rows inner, writing one column per line:

do j = 1, ncol
   write(unit, '(*(f8.2))') field(:, j)   ! a whole column, contiguous
end do

The inner traversal now runs down a column (field(:, j)), which is contiguous in memory (7.24), so this version walks memory in order. For output the speed difference is negligible, but the same reordering matters enormously for the compute loops of Part VII.

7.26 code·=···007. i5.3 prints the integer 7 with at least 3 digits (zero-padded to 007), right-justified in a width-5 field, giving two leading spaces. The .3 is the minimum-digit count m of the iw.m form — useful for fixed-width numeric labels and zero-padded filenames.

7.27 Open a missing file with status='old', capture the failure, and stop cleanly:

integer :: u, ios
character(len=200) :: msg
open(newunit=u, file='does_not_exist.dat', status='old', action='read', &
     iostat=ios, iomsg=msg)
if (ios /= 0) then
   print '(a)', 'open failed: ' // trim(msg)   ! e.g. "No such file or directory"
   error stop
end if

Without iostat/iomsg, the open aborts the program with a terse runtime error; with them, you print a message a human can act on and exit with a nonzero code via error stop. This is the guard-every-open pattern of §7.6.


Chapter 8 — Modules

Solutions to the daggered (†) and odd-numbered problems. Compilable ones (8.1, 8.8, 8.10, 8.14, 8.25, 8.26, 8.28) are worked in full in code/exercise-solutions.f90; the code here is the essential fragment.

8.1 A constants module drawing dp from kinds:

module constants
  use kinds, only: dp
  implicit none
  private
  public :: pi, two_pi
  real(dp), parameter :: pi     = 3.14159265358979_dp
  real(dp), parameter :: two_pi = 2.0_dp * pi
end module constants

The program uses both and prints them; compile order is kinds first (nothing depends on it), then constants, then the program:

$ gfortran -std=f2018 -Wall kinds.f90 constants.f90 main.f90 -o main && ./main
pi     =   3.1416
two_pi =   6.2832

(pi to 4 decimals is 3.1416; two_pi = 6.28319… rounds to 6.2832.) See code/exercise-solutions.f90.

8.3 use kinds, only: dp gives the current unit access to only the entity dp from module kinds (not anything else kinds may export). Two reasons the only: clause is better than a bare use kinds: (1) it documents the dependency — a reader sees exactly what this file takes from kinds; (2) it prevents name clashes and surprises — if kinds later grows a new public name that collides with a local name here, the bare use would break or shadow silently, while only: dp is unaffected. (It also makes the code's true dependencies auditable, which matters for large builds.)

8.5 A bare private statement sets the module's default accessibility to hidden: every entity is private unless you explicitly list it in a public statement. It is the recommended idiom because it fails closed — any helper, variable, or type you add later stays private automatically, so you can only ever expose something by a deliberate public :: line, never by forgetting to hide it. The reverse (public by default, private the exceptions) fails open: forget to hide one internal and it leaks into your API, where a client may come to depend on it and you can no longer change it.

8.6 It compiles but fails at LINK time, with a message such as:

undefined reference to `jacobi_'

Why: jacobi is private (no public :: jacobi), so use solver does not import its name. The subtle point is that implicit none constrains only data typing, not procedure calls — so call jacobi(field) is not a compile error; gfortran simply treats the unknown jacobi as an external subroutine and compiles the call. At link time there is no such external procedure (the module's real one is a different, private symbol), so the linker cannot resolve it. This is a classic confusion: calling a private procedure gives a baffling link error, not a clean compile error. Two fixes. (a) Keep it private: call the public entry point instead — call step(field, ...), letting step call jacobi internally (correct if jacobi is genuinely an internal detail). (b) Publish it: add jacobi to the module's public list if it is truly part of the API. (Bonus: implicit none (external), added in Fortran 2018, would turn this into a clear compile-time error by requiring every external procedure to be explicitly declared — a good habit for catching exactly this mistake early.)

8.7 "Explicit interface for free" means that, simply because a procedure lives in a module you use, the compiler knows its full signature at every call site and checks each call against it — you get the interface without writing an interface block. Three Chapter 6 features that require an explicit interface (and therefore only work when the procedure is a module procedure, or has an interface block): (1) keyword arguments (call step(dt=…, alpha=…)) — the caller must know the dummy names; (2) optional arguments — the caller must know which arguments may be omitted; (3) assumed-shape array arguments (x(:,:)) — the shape/bounds are passed through the interface. (Also: automatic checking of argument type/rank/intent.)

8.8 Prediction: total = 1.5 + 2.5 + 3.0 = 7.0, printed with f6.2:

total =   7.00

The point of the exercise is the encapsulation: total is a private module variable, so run cannot touch it directly — only add and total_so_far can — and it is implicitly save, so it accumulates across the three calls.

8.9 A public variable can be written by any unit that uses the module, from anywhere, so the module can no longer guarantee anything about its own state — the compiler cannot help you find who set it wrong. Keeping the variable private and mutating it only through public procedures means the module controls every change: it can validate inputs, maintain invariants, and be reasoned about in isolation, because the compiler guarantees no outside code can reach the variable's name. The guarantee is the difference: with public the compiler promises nothing; with private it promises the state is untouchable except through the interface you designed.

8.11 The two problems submodules solve: (1) Recompilation cascades — with everything in one module, editing any procedure body regenerates the module's .mod, forcing every file that uses it to recompile; moving the body into a submodule means editing it recompiles only the submodule, because the parent's interface (.mod) is unchanged. (2) Circular dependencies — two modules whose implementations need each other cannot both use each other (Fortran forbids the cycle); putting one implementation in a submodule lets it use the other module freely, since a submodule does not create a cycle in the module dependency graph.

8.12 False. Editing a procedure body inside a submodule changes only the submodule; the parent module's interface (.mod) is unchanged, so files that use the parent do not need recompiling. What is recompiled: the submodule itself (and the program is relinked). What is not: the parent module and all its other users. (That is precisely the build-time benefit submodules exist to provide.)

8.13 In the module procedure … end procedure shorthand, the argument declarations are inherited from the interface in the parent module:

submodule (plate_geom) impl
contains
  module procedure cell_count
    n = nx * ny           ! nx, ny, and result n all come from the interface
  end procedure cell_count
end submodule impl

Both forms compile identically; the shorthand simply avoids restating the signature.

8.14 The module replacing COMMON /GRID/ NX, NY, DX, DY:

module grid
  use kinds, only: dp
  implicit none
  public                       ! a data module: the shared state is the interface
  integer  :: nx = 0, ny = 0
  real(dp) :: dx = 0.0_dp, dy = 0.0_dp
end module grid

The first two lines of a modern setup that gains access — no COMMON in sight:

subroutine setup
  use grid, only: nx, ny, dx, dy
  implicit none
  ...

Every routine that uses grid sees the same typed declarations, checked by the compiler — the disagreement that plagued the COMMON version (Case Study 1) is now impossible. See code/exercise-solutions.f90.

8.15 The use statement removes the need for INCLUDE 'grid.inc'. Instead of pasting the same declaration text into twelve files (which nothing checks — a typo in one copy diverges silently), the COMMON block becomes a single module grid, and each file writes use grid. The declaration exists in exactly one place, and use imports the named, typed entities the compiler tracks — so the twelve files cannot fall out of sync, because there is only one source of truth to begin with.

8.16 Three things a module can do that a COMMON block cannot: (1) export procedures and derived types, not just variables — COMMON shares data only; (2) enforce types and access controlpublic/private and compiler-checked declarations, versus COMMON's unchecked memory overlay; (3) offer selective, renamed import via use … only: / =>, so each file takes exactly what it needs, versus COMMON forcing every sharer to declare the whole block. (Also acceptable: give the shared state a single authoritative definition; provide an explicit interface for its procedures.)

8.17 Dependencies: util (none), grid (uses util), physics (uses util, grid), main (uses grid, physics). A valid order compiles each file after everything it uses:

$ gfortran -std=f2018 -Wall util.f90 grid.f90 physics.f90 main.f90 -o app

It works because it is a topological sort of the dependency graph: util first (its .mod must exist before grid, physics), then grid (needed by physics and main), then physics, then main last. (util.f90 physics.f90 grid.f90 main.f90 would failphysics needs grid.mod, not yet built.)

8.19 A .mod file stores the module's public interface: the names, types, kinds, ranks, and procedure signatures it exports — everything a user needs at compile time to check use statements and calls. It does not contain the compiled machine code. That code lives in the object file (foo.o), produced from the same source. So even though foo.mod lets other files compile against the module, you must still link foo.o (directly or via its .a/.so) to supply the actual executable instructions — compiling against the interface and linking the code are two separate steps.

8.21 (a) timers belongs below heat_solver in the layer diagram — it is a foundation (heat_solver will call it), so it sits in a lower layer (using only kinds), with the arrow pointing down from heat_solver to timers. (b) Only heat_solver changes — it adds use timers and the tic/toc calls. (c) heat_io does not need recompiling for design reasons: it does not use timers or heat_solver, so nothing it depends on changed. (In a plain build you would still recompile timers and heat_solver and relink; the point is the change does not ripple sideways to heat_io, because module boundaries confine it.)

8.23 The problem is a circular dependency: heat_solver uses heat_io and heat_io uses heat_solver, so neither module can be compiled first and the build fails. It violates the §8.6 principle depend downward only (no sideways/mutual dependencies between peers). Concrete fix: remove the I/O from the algorithm. step should not write debug output — that is the driver's job, done between steps. Keep heat_solver free of any use heat_io; let the driver call a reporting routine after each step; and put any field statistic both truly need (e.g., a max or a norm for re-normalization) in a lower field_ops module that both use. Both capabilities survive, the cycle is gone, and — bonus — an I/O-free step is the one you can later parallelize unchanged.

8.24 (a) Body in the module: changing it regenerates the .mod, so all 200 users recompile at ~3 s each ≈ 600 s (10 minutes), plus the module itself and the link. (b) Body in a submodule: the parent .mod is unchanged, so no user recompiles; only the submodule rebuilds and the program relinks ≈ 5 s. Ratio ≈ 120×. The lesson: for a foundational module that many files depend on and whose implementation changes often, put the volatile bodies in submodules so day-to-day edits cost seconds, not minutes — on a large team that reclaimed time is enormous, and it is why big Fortran libraries lean on submodules.

8.25 The Fortran port (rank-1 real(dp) array):

module stats
  use kinds, only: dp
  implicit none
  private
  public :: rng, mean
contains
  pure function rng(x) result(r)
    real(dp), intent(in) :: x(:)
    real(dp) :: r
    r = maxval(x) - minval(x)
  end function rng
  pure function mean(x) result(m)
    real(dp), intent(in) :: x(:)
    real(dp) :: m
    m = sum(x) / real(size(x), dp)
  end function mean
end module stats

One way it is safer at the call site: the module gives rng/mean an explicit interface, so the compiler checks that you pass a real(dp) array (right type, right rank) — passing a scalar or an integer array is a compile error, whereas Python discovers the mismatch (if at all) only at run time. For x = [2,5,1,8,4]: rng = 8-1 = 7.00, mean = 20/5 = 4.00. See code/exercise-solutions.f90.

8.26 The field_ops module with the whole-array interior Laplacian:

module field_ops
  use kinds, only: dp
  implicit none
  private
  public :: laplacian_interior
contains
  pure function laplacian_interior(u) result(lap)
    real(dp), intent(in) :: u(:,:)
    real(dp) :: lap(size(u,1), size(u,2))
    integer  :: n1, n2
    n1 = size(u,1);  n2 = size(u,2)
    lap = 0.0_dp
    lap(2:n1-1, 2:n2-1) = u(1:n1-2, 2:n2-1) + u(3:n1,   2:n2-1) &
                        + u(2:n1-1, 1:n2-2) + u(2:n1-1, 3:n2  ) &
                        - 4.0_dp * u(2:n1-1, 2:n2-1)
  end function laplacian_interior
end module field_ops

For a $4\times4$ field with $u(i,j)=i^3$ (column values $1,8,27,64$ down each column), the discrete second difference of $i^3$ is $6i$, so the interior values are 12 at $i=2$ and 18 at $i=3$ (both columns):

    12.0    12.0
    18.0    18.0

This is the whole-array section technique of Ch. 5, now packaged as a reusable module procedure. See code/exercise-solutions.f90.

8.27 Placing step(field, alpha, dt) in a module gives every caller its explicit interface, which is exactly what those Ch. 6 features need across files. The assumed-shape dummy field(:,:) receives its bounds through the interface — the caller passes a 4×4 (or 1000×1000) array and step learns the shape via size(field,…); an optional :: verbose argument works because the interface tells the caller that argument may be omitted and lets present(verbose) be checked inside. If step were an external procedure with no interface, the compiler would assume an old-style implicit interface: the assumed-shape array could not be passed correctly (no shape descriptor is transmitted), optional/keyword use would be rejected or silently wrong, and argument mismatches would go unchecked — the call might compile and then corrupt memory. The module is what makes Ch. 6's conveniences safe across file boundaries.

8.28 write_field with a clean iostat check:

subroutine write_field(field, filename)
  real(dp),         intent(in) :: field(:,:)
  character(len=*), intent(in) :: filename
  integer :: unit, i, ios
  open(newunit=unit, file=filename, status='replace', action='write', iostat=ios)
  if (ios /= 0) then
     print '(a)', 'write_field: could not open '//trim(filename)
     return                       ! fail cleanly instead of crashing
  end if
  do i = 1, size(field,1)
     write(unit, '(*(f8.2))') field(i, :)
  end do
  close(unit)
end subroutine write_field

Why newunit= is especially valuable in a module: a module procedure may be called by many, independently written parts of a growing program, and if two of them hard-coded the same unit number (open(unit=10,…)) they would clash unpredictably. newunit= asks the runtime for a guaranteed-unused unit, so the routine is safe to call from anywhere — exactly the kind of accidental global collision that modules exist to prevent. See code/exercise-solutions.f90.


Chapter 9 — Derived Types

Solutions to the daggered (†) and odd-numbered problems. Design problems accept any correct type layout; the reference is one good answer. The computational problems (9.11, 9.13, 9.15) are also compilable in code/exercise-solutions.f90 with hand-computed expected output.

9.1 A derived type is a data type you define yourself by grouping other data under one new type name; a component is one of the named members it groups. You access a component with the percent operator % (e.g. p%x, and chained p%pos%x for nested types).

9.3 The passed-object dummy argument must be declared polymorphicclass(name), never type(name). It must be class so the binding can be inherited by an extension of the type (Chapter 10); the compiler rejects type on a passed object outright.

9.5 With an allocatable component, b = a performs a deep copyb receives its own storage holding a copy of the values, independent of a. With a pointer component, b = a copies the pointer (a shallow copy), so b and a share the same storage — change one and the other changes.

9.7 Output: 5.00. The segment runs from s%a = (0,0) to s%b = (3,4); the length is $\sqrt{(3-0)^2 + (4-0)^2} = \sqrt{9+16} = \sqrt{25} = 5$. f6.2 prints 5.00 right-justified in width 6 → 5.00. (The point of the problem is the nested access s%b%x etc.)

9.9 Output: 125.00. deposit has intent(inout) on self, so each call accumulates: $0 + 100 + 25 = 125$. f8.2 prints 125.00 in width 8 → 125.00. Key idea: the object is passed automatically as self, and an inout method mutates it in place.

9.11 (†, code) See code/exercise-solutions.f90. Define rectangle with real(dp) components width, height, and type-bound functions area (= width*height) and perimeter (= 2*(width+height)), each with class(rectangle), intent(in) :: self. For a 3×4 rectangle: area = 12.000, perimeter = 14.000.

9.13 (†, code) See code/exercise-solutions.f90. vec2 with x, y and type-bound add(self, other) returning a new vec2, and norm(self) returning $\sqrt{x^2+y^2}$. $(3,4)+(1,2) = (4,6)$, and $|(3,4)| = 5$. Output: a + b = 4.00 6.00, |a| = 5.000.

9.15 (†, code) See code/exercise-solutions.f90. running_stat holds n (count), s (sum), s2 (sum of squares); push(self,x) increments all three (intent(inout)); mean = s/n; var = s2/n - (s/n)**2 (population variance). On [2,4,4,4,5,5,7,9]: n=8, sum =40mean = 5.000; sum of squares = 232var = 232/8 - 25 = 29 - 25 = 4.000.

9.17 (†) Won't compile: the passed object self is declared type(circle); a type-bound procedure's passed object must be class(circle). Fix: class(circle), intent(in) :: self.

9.19 (†) data is a pointer component, so q = p copies the pointer and q%data ends up aimed at the very same array as p%data (shallow copy / aliasing). Fix: declare it an allocatable component, real(dp), allocatable :: data(:); then q = p deep-copies and p, q are independent. (If a pointer is genuinely required — Chapter 11 — you must copy by hand: allocate(q%data(size(p%data))); q%data = p%data.)

9.21 (†) Port of the Python @dataclass:

module particles
  use, intrinsic :: iso_fortran_env, only: dp => real64
  implicit none
  type :: particle
    real(dp) :: x, y, mass
  contains
    procedure :: ke_from => particle_ke
  end type particle
contains
  pure function particle_ke(self, vx, vy) result(ke)
    class(particle), intent(in) :: self
    real(dp), intent(in) :: vx, vy
    real(dp) :: ke
    ke = 0.5_dp * self%mass * (vx**2 + vy**2)
  end function particle_ke
end module particles

What Fortran gives you that the Python version does not: intent(in) safety on every argument (the compiler forbids accidental mutation), a statically fixed layout the compiler can optimize, and whole-object copy semantics for q = p.

9.23 (†) Two declaration layouts for 1000 particles. (a) Array-of-Structures — one array of a particle type:

type :: particle
  real(dp) :: px, py, pz, vx, vy, vz
end type particle
type(particle) :: bodies(1000)

(b) Structure-of-Arrays — one type holding six component arrays:

type :: particles
  real(dp) :: px(1000), py(1000), pz(1000)
  real(dp) :: vx(1000), vy(1000), vz(1000)
end type particles
type(particles) :: bodies

Prefer AoS when a loop touches most fields of one body at once (a force calculation reads a body's whole state); prefer SoA when a loop sweeps one field across all bodies (better contiguity → better vectorization). Measured in Part VII.

9.25 u is $4096 \times 4096 = 16{,}777{,}216$ elements; at 8 bytes each that is $134{,}217{,}728$ bytes $\approx$ 134 MB (128 MiB). Two fields (current + next) $\approx$ 268 MB. Single precision (real32, 4 bytes) halves each: $\approx$ 67 MB per field, $\approx$ 134 MB total. (Yes, single precision halves the footprint — at the cost of accuracy; the precision trade is Chapter 20.)

9.24 (†) One particle = 7 real(dp) = $7 \times 8 = 56$ bytes. An array of $1{,}000{,}000$ particles = $56 \times 10^{6}$ bytes = 56 MB (taking $1\ \text{MB} = 10^{6}$ bytes).

9.26 (†) Cache lines are ~64 bytes. - SoA: consecutive masses are 8 bytes apart, so one 64-byte line holds 8 masses — every fetched byte is useful. Useful : fetched $\approx 64 : 64 = 1$. - AoS: consecutive masses are 56 bytes apart, so a 64-byte line brings in essentially one mass (8 useful bytes) plus 56 bytes of position/velocity the loop does not want. Useful : fetched $\approx 8 : 64 = 1/8$.

For a mass-only, bandwidth-bound loop the AoS layout wastes roughly seven-eighths of memory bandwidth, so SoA can be up to about faster. (Order-of-magnitude reasoning; the real ratio depends on hardware and prefetching.)

9.27 (†) Module skeleton with access control:

module shapes
  use, intrinsic :: iso_fortran_env, only: dp => real64
  implicit none
  private                       ! everything hidden by default
  public :: rectangle           ! re-export only the type
  type :: rectangle
    real(dp) :: width, height
  contains
    procedure :: area => rect_area
  end type rectangle
contains
  pure function rect_area(self) result(a)
    class(rectangle), intent(in) :: self
    real(dp) :: a
    a = self%width * self%height
  end function rect_area
end module shapes

private makes every entity inaccessible from outside by default; public :: rectangle re-exposes only the type. Consequently rect_area is not callable by name from a user — it is reachable only as the binding r%area(). This is the "public interface, hidden implementation" discipline from Chapter 8.

9.29 (†) pure promises the compiler the procedure has no side effects observable outside it: no I/O, no modifying global or host-associated variables, and (for a pure function) no modifying its arguments — its result depends only on its inputs. norm and area qualify: they only read self (intent(in)) and return a value. deposit does not qualify as a pure function because it changes self (intent(inout)); a function that mutates its argument is exactly the side effect pure forbids. (It could legally be a pure subroutine, which may modify intent(inout) arguments, but it is not a value-returning read-only operation.) Marking the read-only methods pure lets the optimizer reorder and elide calls — the payoff foreshadowed in Chapters 6 and 27.


Chapter 10 — Object-Oriented Fortran

Solutions to the daggered (†) and odd-numbered problems. The computational "Design it" (10.16) is also worked as runnable code in code/exercise-solutions.f90. Design problems admit variations; a model answer is given.

10.1 The declared type is the type in the declaration, fixed and known to the compiler; the dynamic type is the actual type held at run time, which for a class(...) variable may be any extension and may change. For class(shape_t), allocatable :: s holding a circle_t: declared type = shape_t, dynamic type = circle_t.

10.3 A dog_t inherits (a) all of animal_t's components and (b) all of its type-bound procedures (which it may override). To call the parent's version of an overridden speak from inside dog_t's version, reach through the parent component: call self%animal_t%speak().

10.5 A final procedure is not a dispatched call: finalization is applied to the storage of a specific concrete type, and each type in a hierarchy has its own finalizer(s) invoked for its own part. There is no run-time type selection to perform, so the argument is type(...) (the exact type being finalized), not class(...). (Making it class(...) is in fact rejected as a final argument.)

10.7 A rectangle_t(width=5, height=5) gives area = 5*5 = 25.0. print '(f9.5)' prints 25.00000.

10.9 The boxes hold circle_t, rectangle_t, circle_t, so type is (circle_t) matches the first and third and class default matches the second. Output:

circle
other
circle

10.11 type(solver_t) :: s is illegal because solver_t is abstract — abstract types cannot be instantiated. Fix: declare a polymorphic handle and allocate a concrete extension, e.g. class(solver_t), allocatable :: s then allocate(heat_solver_t :: s).

10.13 h%area() returns 0 because hexagon_t inherited shape_t's concrete area (which returns 0) and never overrode it — a body exists, so there is no error, just a silent wrong answer. The fix that catches it at compile time is to make shape_t abstract with a deferred area: then any concrete extension that fails to implement area (or fails to mark itself abstract) is rejected by the compiler. This is exactly the §10.4 improvement over §10.2's placeholder.

10.15 circle_t extends shape_t, and in a structure constructor the inherited components come first — so positional circle_t(2.0_dp) tries to put 2.0_dp into label (a character), a type mismatch. Fix: name the component: circle_t(radius = 2.0_dp) (the inherited label then takes its default).

10.16 (model — runnable in code/exercise-solutions.f90.) Add type, extends(shape_t) :: triangle_t with base, height and a triangle_area returning 0.5_dp*self%base*self%height, bound area => triangle_area. With circle $r=1$ ($\pi \approx 3.14159$), rectangle $2\times3$ ($=6$), triangle base $4$ height $5$ ($=10$), the total is $\approx 19.14159$. The key observation: the summing loop do i=1,size(coll); total = total + coll(i)%obj%area(); end do names no concrete type, so adding triangle_t changed nothing in it.

10.17 (model) Add type, extends(solver_t) :: decay_solver_t whose step sets each interior point to u * (1 - self%alpha*self%dt). With $\alpha=1,\ \Delta t=0.1$ the factor is $0.9$, so an interior point at $100$ becomes $100 \to 90 \to 81 \to 72.9$ after three steps. In the driver, only the allocation line changes — allocate(decay_solver_t :: sim) in place of allocate(heat_solver_t :: sim); the time loop and everything else are untouched, which is the point of the abstract interface.

10.19 Use the "array of boxes" idiom: define type :: solver_box_t; class(solver_t), allocatable :: s; end type, make type(solver_box_t) :: sims(n), allocate each sims(k)%s to a different concrete solver, and loop call sims(k)%s%step(fld). It cannot be a bare class(solver_t), allocatable :: sims(:) because a polymorphic array requires every element to share one dynamic type; the box (monomorphic) lets each element's s differ.

10.21 Fortran: type :: shape_box_t; class(shape_t), allocatable :: obj; end type then type(shape_box_t) :: shapes(2) with each obj allocated to a different shape. Fortran needs the wrapper because a polymorphic array is uniform (one dynamic type for all elements), whereas a Python list is a container of independent references. What Fortran buys in return: the element types and the area interface are checked at compile time, and once a dynamic type is known the call can be generated as fast code — Python pays a run-time lookup on every element, every call.

10.23 One dispatch per step over $10^5$ steps, at ~20 cycles (~$6.7\times10^{-9}$ s at 3 GHz), totals about $10^5 \times 6.7\times10^{-9} \approx 6.7\times10^{-4}$ s of dispatch overhead — under a millisecond — against a 30-minute ($1800$ s) run, i.e. a fraction of order $10^{-6}$. Coarse-grained dispatch is immeasurable. Contrast 10.22: the same mechanism inside the per-cell loop is ruinous. The lesson: the cost of polymorphism is not intrinsic — it is entirely about where (how often) the dispatch fires. Put it at the coarse grain.

10.25 Dependencies: heat_types uses kinds; solver_base uses kinds and heat_types; heat_solver_oo uses kinds, heat_types, and solver_base. A valid compile order is therefore kinds → heat_types → solver_base → heat_solver_oo. heat_solver_oo cannot precede solver_base because heat_solver_t extends solver_t and implements its deferred step, so the compiler needs solver_base's .mod file (its interface) already built.

10.27 For a step that only reads the solver's parameters, the passed object is class(solver_t), intent(in) :: self. For a step that updates an internal step counter (mutating state), it is class(solver_t), intent(inout) :: self. In both cases it is class, never type, so the binding accepts every extension; only the intent changes with whether the method mutates the object.


Even-numbered non-daggered problems (10.2, 10.4, 10.6, 10.8, 10.10, 10.12, 10.14, 10.18, 10.20, 10.22, 10.24, 10.26, 10.28) are left for the reader; 10.18, 10.20, 10.22, 10.24 are daggered and answered in the body of this file or, for the design/estimate problems, admit a range of correct responses along the lines sketched above.

10.18 (model — daggered) Define type, abstract :: boundary_t with procedure(apply_i), deferred :: apply, whose interface is subroutine apply_i(self, fld); class(boundary_t), intent(in) :: self; type(field_t), intent(inout) :: fld. Then dirichlet_t (carries a fixed edge value; its apply writes it to the boundary cells) and neumann_t (its apply copies each neighbouring interior value to the edge, a zero-gradient condition). A solver that holds a class(boundary_t), allocatable :: bc and calls self%bc%apply(fld) each step gets its boundary treatment chosen at run time; swapping Dirichlet for Neumann changes one allocation, and the solver body is untouched. (Composition over inheritance — the solver has a boundary.)

10.20 (model — daggered) type, abstract :: shape_t with procedure(area_i), deferred :: area; type, extends(shape_t) :: circle_t (component r, area = pi*r**2); type, extends(shape_t) :: square_t (component s, area = s*s). What Fortran forces that Python does not: explicit declaration of every argument's type, kind, and intent; the real(dp) result kind; the import of names into the abstract interface; and — via deferred — a compile-time guarantee that every subclass actually provides area (Python only discovers a missing override when NotImplementedError is raised at run time).

10.22 (model — daggered) Per cell the kernel does ~8 flop; at $10^{10}$ flop/s that is ~$0.8$ ns of arithmetic. A per-cell dispatch costs ~20 cycles (~$7$ ns at 3 GHz) and blocks vectorization, making the arithmetic itself ~4× slower (~$3.2$ ns). So per-cell cost goes from ~$0.8$ ns to ~$7 + 3.2 \approx 10$ ns — an order-of-magnitude slowdown, dominated by the un-inlinable dispatch. Over a $10^6$-cell grid that is the difference between ~$0.8$ ms and ~$10$ ms per step. This is precisely why class must never appear in the per-cell loop; move it to once-per-step (see 10.23).

10.24 (model — daggered) Versus a plain array of one concrete type, an array of boxes each holding an allocatable polymorphic component carries: a per-element type descriptor, a pointer indirection to reach each obj, and scattered (non-contiguous) storage, so iterating chases pointers and suffers cache misses instead of streaming contiguous memory. This dominates for a million small objects (indirection and misses per tiny payload) but is negligible for ten large ones (the one-time indirection is amortized over a big contiguous payload). It is the array-of-structures-of-pointers penalty, and the reason hot data stays flat and monomorphic.


Chapter 11 — Pointers, Targets, and Dynamic Data Structures

Solutions to the daggered (†) and odd-numbered problems. The computational ones (11.11, 11.20, 11.27, 11.28) are also worked as compilable code in code/exercise-solutions.f90.

11.1 Trace it as alias-vs-value. p => x aliases x; p = 10.0_dp writes 10 through p into x, so x = 10. Then p => y only moves the alias to y — it copies nothing into y, which stays 7. The print reads x, y:

  10.0   7.0

The trap the problem sets: a reader who thinks p => y somehow "assigns" would expect y to change. It does not — => relocates the label, = writes the value.

11.3 The three statuses are associated, disassociated, and undefined. You must never pass an undefined pointer to associated — the result is undefined behavior (it may return either value or crash, and may differ between machines). Give every pointer a defined status at birth (=> null() or nullify) so associated is always a legal question.

11.5 False. p = a is value assignment: if p is associated, it copies a's value through p into the object p currently points at; it does not change the association. The statement that changes what p points to is pointer assignment, p => a.

11.7 q => p makes q a second alias for the same allocated memory. deallocate(p) frees that memory and disassociates p, but q is a separate variable the deallocation never touched: it still holds the old address and now dangles. associated(q) may return .true. because the standard does not require it to notice that the target was freed through a different pointer — so the test lies. print *, q then reads freed memory: undefined behavior (a crash if you are lucky, silent garbage if not). Correct cleanup: after deallocate(p), nullify(q) and never dereference q again. Better still, do not create the alias if you intend to free the memory.

11.9 Any time you have more than one possible target and must know which one p currently aliases. For example, a traversal pointer p walking a list toward a known sentinel node: associated(p) only tells you p points at some node, but associated(p, sentinel) tells you whether it has reached the sentinel specifically — the actual loop-termination test. Another case: verifying that a pointer returned by a routine aliases the exact array you passed in (associated(result, my_array)) rather than some other target.

11.11 Walk a local pointer so head is never modified:

integer function list_length(head) result(count)
  type(node_t), pointer, intent(in) :: head
  type(node_t), pointer :: p
  count = 0
  p => head
  do while (associated(p))
    count = count + 1
    p => p%next
  end do
end function list_length

For the three-node list 3 -> 2 -> 1 it returns 3. Because it advances a local p and never pointer-assigns to head, the list is left untouched (which is why head can be intent(in)).

11.13 The loop deallocate(p); p => p%next frees the node and then reads p%next from the freed node — a dangling access, undefined behavior. The rule is save the next pointer before you free:

p => head
do while (associated(p))
  nxt => p%next     ! save first...
  deallocate(p)     ! ...then free
  p => nxt
end do
nullify(head)

You cannot read a node after you have deallocated it; introduce nxt to hold the link while the node is still alive.

11.15 p = 5.0_dp when p is disassociated writes a value through a pointer that points at nothing — a null-pointer dereference, undefined behavior (typically an immediate crash). It differs from p => …, which would set the association rather than write through it. Guard by only assigning through a pointer you know is associated: test if (associated(p)) first, or allocate(p) / point it at a target before writing. (gfortran -fcheck=all, Chapter 13, can catch many such dereferences at run time.)

11.17 Modernized:

real(dp), allocatable :: work(:)
allocate(work(n))
! ... use work ...
! deallocate(work)   ! optional — automatic on scope exit

Advantages gained: (1) automatic deallocation on scope exit — no leak on an early return or error branch you forgot; (2) no dangling — the allocatable is the sole owner, so there is no stray alias to outlive it; (3) no association-status pitfallsallocated() is always a valid question, and there is no "undefined" state; (4) better optimization — a plain allocatable cannot be aliased, so the compiler has full no-aliasing freedom (a pointer forfeits this). For a private scratch array the pointer bought nothing and cost all four; allocatable is strictly better.

11.19 Rule: prefer allocatable; use pointer only when you need aliasing, a genuine linked structure, a polymorphic container, a callback, or C interoperability. Classification: (a) local scratch array → allocatable (you own it, want auto-cleanup); (b) frequently-restructured graph → pointer (true links) — though an index-based array is often better still; (c) handle to a C library's array → pointer (c_f_pointer turns the type(c_ptr) into a usable Fortran pointer); (d) container of several dynamic types → pointer (a polymorphic class(...) pointer/allocatable, resolved with select type); (e) run-time-sized field bundled in a derived type → allocatable component (deep-copied, auto-freed).

11.20 See code/exercise-solutions.f90 for the full buffer_t module. push doubles capacity when full, using move_alloc for the $O(1)$ hand-off. The capacity after each of five pushes is:

push 1 2 3 4 5
logical size n 1 2 3 4 5
capacity 1 2 4 4 8

Capacity doubles only on the push that finds the array full (at sizes 1, 2, 4), giving the sequence 1, 2, 4, 4, 8.

11.21 Every caller must pass storage that is genuinely contiguous — a whole array, a leading section a(1:m), or a full column a(:,j) — and never a strided slice such as a row a(i,:) or a(1:n:2). In return, the compiler may assume unit stride and emit simple, vectorizable load/store code, instead of the general strided-access code it must generate for a plain assumed-shape u(:,:) (which might be strided). It changes no results, only speed. Break the promise — hand it a strided section — and the program is nonconforming; the compiler may make a hidden copy or misbehave.

11.22 (1) Automatic deallocation: a field_t frees its grid u automatically when it goes out of scope, so there is no manual cleanup and no leak on a forgotten exit path. (2) No dangling: the allocatable component is the sole owner of its storage, so no stray alias can be left pointing at freed memory. (3) No aliasing → speed: because nothing can alias a plain allocatable, the compiler optimizes the Chapter 24 stencil sweep freely (the no-aliasing advantage of Chapter 27), which a pointer field would forfeit. The one legitimate pointer use in this book: C interoperability (Chapter 14), where a type(c_ptr) handed across the language boundary becomes a usable Fortran array only via a pointer and c_f_pointer.

11.23 A column a(:,k) is contiguous; a row a(k,:) is not. Fortran is column-major — the first index varies fastest — so a whole column occupies one unbroken run of memory, while a row's elements are separated by the column length (a stride). A contiguous pointer may therefore legally alias a(:,k) but not a(k,:).

11.25 intent(in) on a pointer dummy constrains the pointer's association, not its target's values. Inside the routine you may not change what v points to — no pointer assignment to v, no allocate/deallocate/nullify of v. You may still read v, and (perhaps surprisingly) you may even modify the values of its target through it, because the intent governs the pointer's association status, not the data it aliases. This is a subtle rule worth remembering: to make the target's data read-only, use a non-pointer intent(in) dummy instead.

11.27 See code/exercise-solutions.f90. With $n = 10^{7}$: the linked list uses $10^{7} \times 16 = 1.6\times10^{8}$ bytes ≈ **160 MB**; the plain 4-byte-integer array uses $10^{7} \times 4 = 4\times10^{7}$ bytes ≈ 40 MB. The ratio is — before counting allocator bookkeeping per node, which makes the real gap worse. The list pays for a pointer (and padding) on every element; the array pays nothing beyond the data.


Chapter 12 — String Handling and Text Processing

Solutions to the daggered (†) and odd-numbered problems. The programs among them are in code/exercise-solutions.f90; the port and design problems have more than one good answer, so a model is given.

12.1 For character(len=8) :: t = 'grid': len(t) = 8 (the declared length), len_trim(t) = 4 (the four non-blank characters), and trim(t) = 'grid', a string of length 4. The variable holds 'grid ''grid' plus four trailing blanks.

12.3 'heat' // '_' // 'map' = 'heat_map', of length 8 (4 + 1 + 3). Concatenation's result length is the sum of the operand lengths.

12.5 (see code/exercise-solutions.f90) With 'banana' = 1:b 2:a 3:n 4:a 5:n 6:a: index('banana','ana') = 2 (first a-n-a, at 2–4); index('banana','ana',back=.true.) = 4 (last, at 4–6); scan('banana','n') = 3 (first n); verify('banana','ban') = 0 (every character is one of b, a, n); verify('banana','ba') = 3 (the first character not in {b, a} is the n at 3).

12.7 (see code) Starting s = 'ab' (length 2), then s = s // 'ab' three times gives lengths 2 → 4 → 6 → 8 and the final string 'abababab'. Each assignment reallocates the deferred-length s to fit the concatenated result.

12.9 (see code) name = f"heat_{step:06d}.vtk" becomes an internal write: write(name, '(a, i6.6, a)') 'heat_', step, '.vtk'. The Python :06d — "decimal, width 6, zero-padded" — is exactly the Fortran i6.6 descriptor (width 6, minimum 6 digits). For step = 7 both produce heat_000007.vtk.

12.11 (port line.split()) The chapter's scan/verify loop reproduces split()'s whitespace behavior: verify(line(pos:), ' ') finds the first non-blank (the start of the next token), and scan(line(first:), ' ') finds the next blank (the end). Because verify skips a run of blanks in one step, several spaces between words are treated as a single separator — so, like split() with no argument, the tokenizer never emits an empty token. (Python's bare split() also drops leading/trailing whitespace, which the loop does too, since it exits when only blanks remain.)

12.13 if (index(line, ',')) call handle_csv(line) does not compile because if requires a logical expression, and index returns an integer (a position). The author meant "if the line contains a comma," i.e. if (index(line, ',') > 0) call handle_csv(line). Fortran's type check catches here what C's implicit int-to-bool conversion would silently accept.

12.15 A tokenizer that "splits at every blank" — emitting a token each time it crosses a blank — produces an empty token when two blanks are adjacent, because the stretch of characters between the first blank and the second is empty. Searching with verify avoids this: verify(line(pos:), ' ') jumps over all consecutive blanks at once to the next non-blank, so a run of delimiters is consumed as a single separator and no empty token is ever created. The fix is structural — skip-then-take with verify/scan, not test-each-character.

12.17 (see code, parse_config) Split on the = with eq = index(line, '='); the key is trim(adjustl(line(:eq-1))) and the value is parsed from line(eq+1:) with an internal read. index finds the =; trim(adjustl(...)) is used because the key may have blanks on both sides of the = (alpha = 0.25), and adjustl removes the leading ones while trim removes the trailing ones, leaving the bare key 'alpha'. For 'alpha = 0.25' the result is key alpha, value 0.250.

12.19 (see code, frame_name) Generalize with three fields: write(buf, '(a, a, i6.6, a, a)') trim(prefix), '_', step, '.', trim(ext), then name = trim(buf) for a right-sized deferred-length result. frame_name('temp', 42, 'dat') = 'temp_000042.dat'. (A single format string handles the whole name; trim on prefix/ext guards against a fixed-length caller's blanks.)

12.21 (see code, log_line) One internal write assembles the message: write(buf, '(a, i6.6, a, f0.3, a, f0.1)') 'step ', step, ' t=', t, ' Tmax=', tmax. For step = 123, t = 1.23, tmax = 99.5 this is 'step 000123 t=1.230 Tmax=99.5' (i6.6000123, f0.31.230, f0.199.5). Return trim(buf) as a deferred-length string.

12.23 (Interleaved — Ch. 5.) character(len=16) :: names(100) is a fixed-length array: every element is 16 characters, so a name like heat_1.vtk wastes its unused trailing columns, and a name longer than 16 is silently truncated — but all 100 elements sit in one contiguous block, cheap to allocate and sweep. character(len=:), allocatable :: names(:) still requires all elements to share one length (a Fortran rule for arrays), so it does not give per-element sizing; what it does give is a length chosen at run time to fit the widest name, allocated once. Use the fixed array when the width is known and uniform (filenames like heat_NNNNNN.vtk are); use the allocatable array when the needed width is discovered at run time. For genuinely ragged text, an array of a derived type wrapping a deferred-length component is the tool.

12.24 (Interleaved — Ch. 7.) Add iostat= to the internal read and inspect it instead of letting a bad field crash:

integer :: ios
read(text, *, iostat=ios) value
if (ios /= 0) then
  ! text was not a valid number: reject it, use a default, or report the line
end if

On a good conversion ios is 0; on a malformed field ('12x', 'N/A') it is a nonzero processor-defined value (and negative for end-of-record). You branch on ios /= 0 to handle the error gracefully — the same iostat mechanism used for file reads in Chapter 7, generalized to defensive input validation in Chapter 13.

12.25 (Interleaved — Ch. 3 / Ch. 11.) character(:), allocatable is preferred over a character, pointer string for the same reasons allocatable beats pointer for arrays (Ch. 11): (1) automatic deallocation — the string is freed when it goes out of scope, with no manual cleanup and no leak; and (2) no aliasing — an allocatable string cannot be aliased by a pointer, so assignment deep-copies (s = t gives an independent, correctly sized copy) and the compiler can optimize freely. A pointer string would demand manual deallocate, could dangle, and copies by association rather than value. Prefer allocatable; reach for a character pointer only for the rare aliasing or C-interop case.


Chapter 13 — Error Handling, Debugging, and Defensive Programming

Solutions to the daggered (†) and odd-numbered problems. The computational ones (13.7, 13.15, 13.20) also appear as compilable code in code/exercise-solutions.f90. Where a problem describes a crashing or wrapping program, the diagnostic shown is representative and version-dependent.

13.1 Generalized to memory allocation: allocate/deallocate take stat=/errmsg= exactly as an I/O statement takes iostat=/iomsg=. The shared one-sentence pattern: an operation that can fail returns a status you test (zero = success, nonzero = failure) and hands control back to you, instead of aborting. (In the parallel chapters the coarray statements take stat= too — one pattern, three domains.)

13.3 Any two of: out-of-bounds array access (indexing outside declared/allocated extents); use of an unallocated allocatable or a disassociated/undefined pointer; certain invalid do-loop or array-temporary conditions. All are silent (undefined behavior) without the flag and become located runtime errors with it.

13.5 Defensive programming is writing code that checks its own assumptions and fails early, loudly, and with a diagnostic when one is violated. Two practices from §13.5: validating inputs at the boundary (preconditions) and checking results are sane (postconditions) — plus guarding every failure point and reporting to error_unit with context.

13.7 Line 1 prints 2147483647 (= huge(0_int32)); line 2 prints -2147483648. The phenomenon is signed integer overflow: adding one exceeds the 32-bit signed maximum and, under gfortran's two's-complement arithmetic, wraps to the most negative value. (Standard-wise, signed overflow is not defined; -fsanitize=undefined or -ftrapv makes it trap instead of wrap.)

13.9 With -fcheck=all, the run stops at the assignment line (a(i) = 99) with a message of the shape "Index '6' of dimension 1 of array 'a' above upper bound of 5." Without -fcheck, the write to a(6) is undefined behavior: most likely it writes one element past the array into adjacent memory and the program prints plausible-but-wrong output, or crashes unpredictably, with no indication of the cause.

13.11 q is a dangling pointer. deallocate(p) frees the memory and disassociates p, but q still holds the old address; associated(q) may cheerfully return .true., and reading q is undefined behavior (a read of freed memory). The fix is discipline: nullify(q) immediately after deallocate(p), and never dereference q again. This bug passes every in-language test; valgrind (or -fsanitize=address) is what catches the invalid read (§13.4). It is one more argument for the "prefer allocatable" rule of Chapter 11.

13.13 Comparing reals with == almost never matches, because residual is the result of floating-point arithmetic and is essentially never bit-identical to the literal 1.0e-6_dp. Use a tolerance: if (abs(residual) < 1.0e-6_dp) then (for "close to zero") or if (abs(residual - target) < tol) for a general comparison. See Chapter 20 for why exact equality of computed reals is a mistake.

13.15

integer :: u, ios
character(len=256) :: msg
open(newunit=u, file='config.nml', status='old', action='read', iostat=ios, iomsg=msg)
if (ios /= 0) then
   write(error_unit, '(a)') 'cannot open config: ' // trim(msg)
   error stop 2
end if

How the models differ: Python raises an exception that unwinds the stack until a try/except catches it; Fortran has no exceptions — the open returns a status (iostat) you test, and you halt explicitly with error stop. Same outcome (a message and exit code 2), opposite mechanism: explicit status vs non-local throw.

13.17

if (nx < 3 .or. ny < 3) then
   write(error_unit, '(a, i0, a, i0)') 'config error: grid must be at least 3x3 for the stencil, got ', &
        nx, ' x ', ny
   error stop 2
end if

The five-point stencil updates interior points from their four neighbours, so a grid smaller than 3 in either direction has no interior to update — a precondition of the numerics, checked before any allocation.

13.19 Codes: error stop 2 on a bad configuration, error stop 3 on an allocation failure, error stop 4 on a NaN blow-up (detected by an x /= x postcondition). A wrapper:

#!/bin/sh
./heat "$@"
case $? in
  0) echo "run OK" ;;
  2) echo "FAILED: bad configuration — check the namelist" ;;
  3) echo "FAILED: out of memory — reduce the grid" ;;
  4) echo "FAILED: numerical blow-up (NaN) — reduce dt" ;;
  *) echo "FAILED: unexpected exit code $?" ;;
esac

The distinct codes let the wrapper give a specific remedy for each failure without parsing the program's output.

13.21 Extra bounds comparisons $\approx (5 + 1)\ \text{accesses} \times 10^{8}\ \text{cells} \times 10^{4}\ \text{steps} = 6 \times 10^{12}$ added comparisons over the run. Even at a few comparisons per nanosecond that is on the order of thousands of seconds of pure checking overhead — which is exactly why -fcheck is a development flag: you accept the slowdown to catch bugs while building, then remove it so the production run pays nothing (Chapter 30).

13.23

open(newunit=u, file='heat.nml', status='old', action='read', iostat=ios, iomsg=msg)
if (ios /= 0) then                                   ! missing file / permission
   write(error_unit, '(a)') 'cannot open heat.nml: ' // trim(msg)
   error stop 2
end if
read(u, nml=config, iostat=ios, iomsg=msg)
if (ios /= 0) then                                   ! wrong group name or misspelled key
   write(error_unit, '(a)') 'cannot parse heat.nml: ' // trim(msg)
   error stop 3
end if
close(u)

The open failure (file missing) is distinguished from the parse failure (a &params group or an n_step key) by which statement's iostat is nonzero — and they get different exit codes so a caller can tell them apart.

13.25 The value semantics of an allocatable component make aliasing impossible: b = a deep-copies a%u into freshly allocated storage in b, so the two field_t objects never share memory — changing b cannot affect a. Had u been a pointer component, default assignment would copy the pointer (a shallow copy), leaving b%u aliased to a%u; then a write through one silently changes the other, and deallocating one dangles the other. Allocatable components make that whole class of aliasing bug unwriteable (Chapter 9, Chapter 11).

13.27 It is not a floating-point precision problem — it is the integer-division trap (Chapter 3). 3 / 4 with two integer operands performs integer division, which truncates to 0; assigning 0 to a real frac then stores 0.0. The fix is to make at least one operand real: frac = 3.0_dp / 4.0_dp (or real(3, dp) / 4.0_dp), giving 0.75. Precision loss (Chapter 20) is a different suspect; this one is integer semantics.


Chapter 14 — C-Fortran Interoperability

Solutions to the daggered (†) and odd-numbered problems. Interface-design problems (14.7, 14.10, 14.12, 14.17, 14.25) admit more than one correct form; a model answer is given. The compilable numeric answers (14.19, 14.20) are in code/exercise-solutions.f90.

14.1 The intrinsic module iso_c_binding supplies the C-interoperable kinds (c_int, c_double, …), the c_ptr/c_funptr types, and helpers (c_loc, c_f_pointer). The attribute bind(c) gives a procedure, derived type, or variable a clean, un-mangled C linkage name and the interoperability guarantee.

14.3 c_double is defined to match C's double; dp = selected_real_kind(15, 307) is defined by a precision request. On every mainstream platform they are the same kind value, but declaring boundary-crossing data real(c_double) guarantees the match on any platform and documents that the variable exists to talk to C. Use dp for internal numerics, c_double at the boundary.

14.5 (a) The decoration is name mangling: the compiler encodes scope into the symbol so a module's solve cannot collide with another solve. gfortran emits __linalg_MOD_solve; the scheme is compiler-specific. (b) bind(c, name="solve") makes the emitted symbol exactly solve — no module prefix, no underscore — so a C caller declares and calls solve directly. Without it, C would have to hard-code gfortran's private __linalg_MOD_solve, nailing the program to one compiler.

14.7 int is passed by value, long is the result:

interface
  function factorial(n) bind(c, name="factorial") result(f)
    use, intrinsic :: iso_c_binding, only: c_int, c_long
    integer(c_int), value :: n
    integer(c_long) :: f
  end function factorial
end interface

14.8 Mean of $\{1,2,3,4\}$ is $10/4 = 2.5$, so it prints mean = 2.50.

14.9 twice(21) = 2 \times 21 = 42, so it prints 42. n needs value because C passes the literal 21 by value; without value, Fortran would interpret the incoming value as an address and read garbage from it.

14.10 Interface and driver:

interface
  function dot(n, a, b) bind(c, name="dot") result(s)
    use, intrinsic :: iso_c_binding, only: c_int, c_double
    integer(c_int), value :: n
    real(c_double), intent(in) :: a(n), b(n)   ! const double *  -> by reference
    real(c_double) :: s
  end function dot
end interface
! ... a = [1,2,3], b = [4,5,6] ...
print *, dot(3, a, b)

$\text{dot} = 1\cdot4 + 2\cdot5 + 3\cdot6 = 4 + 10 + 18 = 32$, so it prints 32.00….

14.11 C prototype void scale_c(int n, double *w, double factor);. A call: scale_c(3, w, 2.0); where w is a double[3]. n and factor go by value, w by reference; the routine multiplies every element in place (the caller sees the change because w is not a copy).

14.12 (a) The Fortran kernel:

function sum_squares_c(n, v) bind(c, name="sum_squares_c") result(s)
  use, intrinsic :: iso_c_binding, only: c_int, c_double
  integer(c_int), value :: n
  real(c_double), intent(in) :: v(n)
  real(c_double) :: s
  s = sum(v*v)
end function sum_squares_c

(b) C prototype: double sum_squares_c(int n, const double *v);. This is exactly the sort of tight loop that Chapter 15 wraps for Python, where it beats the pure-Python version by a wide margin.

14.13 The dummy x lacks the value attribute, but C's csquare takes double by value. Fortran passes x's address; C reads it as a double bit pattern and squares nonsense. Fix: real(c_double), value :: x.

14.15 The string is not null-terminated. c_char_"progress: 42%" carries no trailing '\0', so C's puts reads past the end until it stumbles on a stray zero byte, printing garbage. Fix: append the terminator — call puts(c_char_"progress: 42%" // c_null_char).

14.17 Model interface:

subroutine write_field_c(nx, ny, u, fname) bind(c, name="write_field_c")
  use, intrinsic :: iso_c_binding, only: c_int, c_double, c_char
  integer(c_int), value :: nx, ny              ! by value
  real(c_double), intent(in) :: u(nx, ny)      ! by reference; read-only for output
  character(kind=c_char), intent(in) :: fname(*)  ! C's  const char *
end subroutine write_field_c

fname is a c_char assumed-size array. The two sides agree on where the name ends via the null terminator: the C caller passes a string literal (which already includes '\0'), and Fortran finds the end by scanning do while (fname(k) /= c_null_char). Read past the null and you corrupt the filename.

14.19 On LP64: int a at bytes 0–3; four bytes of padding so double b starts 8-byte-aligned at byte 8, filling 8–15; char c at byte 16; then tail padding to the struct's 8-byte alignment. Total $= 4 + 4 + 8 + 1 + 7 = 24$ bytes. c_sizeof of the matching bind(c) type reports 24 (code/exercise-solutions.f90).

14.21 Yes — on every mainstream platform selected_real_kind(15, 307) and c_double are the same kind value (IEEE 754 double). Two names remain because they express different intents: dp is a portable precision request ("at least 15 digits"), c_double is an interoperability guarantee ("exactly C's double"). Keeping both makes each declaration state why the variable has that kind.

14.23 c_loc(a) returns a C address that other code will dereference later, so a must have a stable, addressable home in memory. The target attribute is exactly the promise that an object may be aliased through an address or pointer; without it the compiler may keep the object in a register or relocate it, so its address is meaningless — which is why c_loc (like ordinary pointer association) requires target (or pointer), and why a must also be contiguous, since a C pointer describes an unbroken block.

14.25 (advanced, sketch) To sort a Fortran integer array arr with C's qsort:

  • Two pointers to build. c_loc(arr) (with arr declared target and contiguous) gives the type(c_ptr) base; c_funloc(cmp) gives the type(c_funptr) comparator.
  • The qsort interface takes them by value:

fortran subroutine qsort(base, n, sz, cmp) bind(c, name="qsort") use, intrinsic :: iso_c_binding, only: c_ptr, c_funptr, c_size_t type(c_ptr), value :: base integer(c_size_t), value :: n, sz ! count and element size (c_sizeof of one element) type(c_funptr), value :: cmp end subroutine qsort

  • The comparator matches C's int (*)(const void *, const void *):

fortran function cmp(pa, pb) bind(c) result(r) use, intrinsic :: iso_c_binding, only: c_ptr, c_int, c_f_pointer type(c_ptr), value :: pa, pb integer(c_int) :: r integer(c_int), pointer :: a, b call c_f_pointer(pa, a) ! view the void* as an integer call c_f_pointer(pb, b) r = merge(-1_c_int, merge(1_c_int, 0_c_int, a > b), a < b) end function cmp

Pass int(c_sizeof(arr(1)), c_size_t) as sz and size(arr, kind=c_size_t) as n. The comparator receives two void* and recovers the integers with c_f_pointer, returning negative/zero/positive.


Chapter 15 — Fortran-Python Interoperability

Solutions to the daggered (†) and odd-numbered problems. Compilable Fortran for 15.5, 15.7, 15.12, 15.13, 15.19, and 15.24 is in code/exercise-solutions.f90. Benchmark numbers are machine-dependent; a model result is given and you should report your own.

15.1 f2py is invoked with three meaningful pieces. -c = "compile and build the extension module" (rather than only generating a .pyf signature). -m mymod = name the extension module mymod — this is the name you import in Python, and it is independent of both the source filename and the Fortran module name. kernel.f90 = the Fortran source to wrap. The mymod in -m mymod names the importable thing.

15.3 f2py reads the Fortran intent on each dummy argument to shape the Python interface: intent(in) → a Python argument; intent(out) → a return value (removed from the input arguments); intent(inout) → an argument modified in place (which requires the passed array to be F-contiguous and the right dtype, or f2py raises).

15.5 Add the directive that hides n:

function norm2_kernel(x, n) result(r)
  integer,  intent(in) :: n
  real(dp), intent(in) :: x(n)
  real(dp) :: r
!f2py intent(hide), depend(x) :: n = shape(x, 0)
  r = sqrt(sum(x**2))
end function norm2_kernel

Build (the module is named veclib in the source, and we name the extension module veclib too):

$ f2py -c -m veclib exercise-solutions.f90

Python driver:

import numpy as np, veclib
print(veclib.veclib.norm2_kernel(np.array([3.0, 4.0])))   # -> 5.0

It prints 5.0 because $\sqrt{3^2 + 4^2} = \sqrt{25} = 5$. Note veclib.veclib.norm2_kernel: the outer veclib is the extension module, the inner veclib is the Fortran module the procedure lives in.

15.7 Build smoothlib from code/example-03-smooth.f90 (f2py -c -m smoothlib example-03-smooth.f90), then:

import numpy as np, smoothlib
x = np.array([0., 0., 0., 12., 0., 0., 0.])
print(smoothlib.smoother.smooth(x))   # -> [0. 0. 4. 4. 4. 0. 0.]

Hand-check: endpoints copy (y[0]=0, y[6]=0). Interior three-point averages: y[1]=(0+0+0)/3=0, y[2]=(0+0+12)/3=4, y[3]=(0+12+0)/3=4, y[4]=(12+0+0)/3=4, y[5]=(0+0+0)/3=0. The single spike of 12 spreads into a plateau of three 4's.

15.8 The array np.zeros((4000, 4000)) is C-contiguous (NumPy's default), so every one of the 20,000 step calls forces f2py to copy the whole 128 MB field into Fortran (column-major) order on the way in and copy the result back — the copying swamps the stencil. Fix: create it order='F' (once) and reuse it: field = np.zeros((4000, 4000), order='F').

15.9 False. f2py preserves logical indexing: a[i, j] in Python maps to u(i+1, j+1) in Fortran (the +1 is 1-based indexing, not a transpose). It reconciles the column-major/row-major difference by copying a non-F-contiguous array, never by silently swapping your indices. If it transposed, your grid would come back mirrored — it does not.

15.10 With intent(inout), f2py raises a ValueError complaining the array is not Fortran-contiguous. The reason is the difference from intent(in): an intent(in) argument is read-only, so f2py can safely make an F-contiguous copy, pass the copy, and discard it — correct, just with a per-call cost. An intent(inout) argument must have the routine's writes propagate back to the caller's array; if f2py copied, the writes would land in the copy and be thrown away. Rather than silently lose your results, f2py refuses. The cure is order='F' so no copy is needed.

15.11 Check: a.flags['F_CONTIGUOUS'] (equivalently np.isfortran(a)) → True/False. Convert (copying only if needed): a = np.asfortranarray(a).

15.12 Fortran (in code/exercise-solutions.f90, module difflib):

subroutine diffs(x, d, n)
  integer,  intent(in)  :: n
  real(dp), intent(in)  :: x(n)
  real(dp), intent(out) :: d(n)
!f2py intent(hide), depend(x) :: n = shape(x, 0)
  integer :: i
  d(1) = 0.0_dp
  do i = 2, n
    d(i) = x(i) - x(i-1)
  end do
end subroutine diffs

Build f2py -c -m difflib exercise-solutions.f90. Benchmark (structure identical to code/benchmark.py):

import numpy as np, time, difflib
def diffs_py(x):
    n = len(x); d = np.empty(n); d[0] = 0.0
    for i in range(1, n): d[i] = x[i] - x[i-1]
    return d
x = np.random.rand(5_000_000)
t0 = time.perf_counter(); a = diffs_py(x);            t1 = time.perf_counter()
b = difflib.difflib.diffs(x);                          t2 = time.perf_counter()
assert np.allclose(a, b)
print(f"speedup: {(t1-t0)/(t2-t1):.1f}x")

Model result: pure Python on the order of a second or two, Fortran a few milliseconds — an illustrative 10–100×. Hand-check on x=[1,3,6,10]: d=[0,2,3,4]. The gap is per-iteration interpreter overhead, not the arithmetic (both do n-1 subtractions). Note difflib.difflib.diffs — extension module difflib, Fortran module difflib.

15.13 Fortran (module smooth2dlib in code/exercise-solutions.f90): the four-neighbor average v(i,j) = 0.25*(u(i-1,j)+u(i+1,j)+u(i,j-1)+u(i,j+1)) over the interior, boundaries copied. The Python driver must create the input array with order='F' and dtype=np.float64 so every call is zero-copy; a plain np.zeros((n,n)) would be C-contiguous and copied in/out on each call.

15.14 A 2000 × 2000 float64 array is $4\times10^6$ elements $\times\,8$ bytes $= 32$ MB. Copying it in and the result out is $64$ MB per call; over $100{,}000$ calls that is $64\ \text{MB} \times 10^5 = 6.4\times10^6\ \text{MB} \approx 6.4$ terabytes of pure copying — all of it overhead, none of it arithmetic. Creating the array order='F' drops that to essentially zero copying (the buffer is handed to Fortran by pointer). One keyword erases 6.4 TB of memory traffic.

15.15 ctypes/cffi speak the C ABI: they look up a C symbol name in the shared library and call it with the C calling convention. Without bind(c), gfortran mangles the name — a module-less subroutine foo is typically exported as foo_ (lowercased, trailing underscore) — and uses Fortran's private argument-passing conventions. So lib.foo will not resolve (the symbol is foo_), and even if you guessed the mangled name, the calling convention might not match. bind(c, name="foo") gives it a stable, unmangled C name and the C convention, which is exactly what the FFIs require.

15.17 Two situations favoring ctypes/cffi: (1) you have a prebuilt shared library (.so/.dll) you cannot or should not rebuild from source — ctypes just loads it at runtime, no build step; (2) you want to avoid a NumPy-based build entirely, or you are calling into a large existing C library that Fortran also exposes via bind(c). f2py is clearly better when you are wrapping numerical Fortran with array arguments and you have the source: f2py understands NumPy arrays, matches dtypes, hides dimension arguments, and builds the importable module for you — all the tedious array marshaling ctypes makes you do by hand.

15.18 Have one Fortran call advance the field k steps internally (see advance in Case Study 2 and code/exercise-solutions.f90 pattern): loop k times inside the kernel, snapshotting and updating each time, and return only the final field. This amortizes the boundary-crossing cost because Python crosses into Fortran once per batch of k steps instead of once per step, so the fixed per-crossing marshaling overhead is divided by k. It matters most when the per-call work is small relative to the crossing cost — small grids with many timesteps — and least when each call already does a lot of arithmetic. The trade-off: Python only regains control (to plot or save) every k steps.

15.19 In-place variant (module inplacelib in code/exercise-solutions.f90):

subroutine step_inplace(u, alpha, dt, n, m)
  integer,  intent(in)    :: n, m
  real(dp), intent(inout) :: u(n, m)
  real(dp), intent(in)    :: alpha, dt
!f2py intent(hide), depend(u) :: n = shape(u, 0), m = shape(u, 1)
  real(dp) :: old(n, m), lap
  integer  :: i, j
  old = u
  do j = 2, m - 1
    do i = 2, n - 1
      lap = old(i-1,j)+old(i+1,j)+old(i,j-1)+old(i,j+1) - 4.0_dp*old(i,j)
      u(i,j) = old(i,j) + alpha*dt*lap
    end do
  end do
end subroutine step_inplace

The Python caller must guarantee the array is (1) F-contiguous and (2) float64 (matching real(dp)). If either fails, f2py raises a ValueError rather than copy-and-lose the in-place writes. The payoff is no per-call allocation of a result array — the field is allocated once and written repeatedly.

15.20 Design: the loop and the figures are Python's job; the step/advance kernel is Fortran's. Skeleton:

u = np.zeros((n, n), dtype=np.float64, order='F'); u[0, :] = 100.0
for s in range(nsteps):
    heatlib.heat_kernel.step_inplace(u, alpha, dt)      # FORTRAN: the physics
    if s % 50 == 0:                                      # PYTHON: orchestration + viz
        plt.imshow(u.T, origin='lower', cmap='inferno')
        plt.savefig(f'frame_{s:05d}.png', dpi=120)
        plt.close()
# assemble frame_*.png into a movie with your tool of choice

This is §15.1 exactly: the arithmetic-heavy stencil is compiled Fortran; the cadence, the filenames, and the plotting are Python, where a few matplotlib lines do what would be a chapter of Fortran. (If you batch with advance(u, …, 50), align the batch size with the save cadence so you save exactly at frame boundaries.)

15.21 Amdahl's Law. Normalize the original runtime to 1: the serial 5% is $0.05$ and the kernel 95% is $0.95$. Speeding only the kernel by 50× makes it $0.95/50 = 0.019$; the serial part is unchanged at $0.05$. New runtime $= 0.05 + 0.019 = 0.069$, so the whole-program speedup is $1/0.069 \approx \mathbf{14.5\times}$ — not 50×, because the untouched 5% now dominates. Even an infinitely fast kernel would cap the program at $1/0.05 = 20\times$. This is the whole motivation for Chapter 31: the serial fraction sets the ceiling.

15.23 In a Fortran double loop over a 2-D array, the inner loop should run over the first index because Fortran is column-major (Chapter 5): consecutive first-index values u(i,j), u(i+1,j) are adjacent in memory, so the loop streams through cache lines with the grain of storage instead of jumping across it. The connection to interop: a NumPy array bound for Fortran should be created order='F' for the same reason — so its physical memory layout is column-major and matches what Fortran expects, letting f2py pass it with zero copy. One layout fact, two consequences: loop order inside Fortran, order='F' at the boundary.

15.24 The iso_c_binding kind matching a NumPy float64 is c_double. C prototype (scalars by reference, Fortran's default): double dot(double *x, double *y, int *n);. Fortran (external function in code/exercise-solutions.f90):

function dot(x, y, n) result(r) bind(c, name="dot")
  use, intrinsic :: iso_c_binding, only: c_double, c_int
  implicit none
  integer(c_int), intent(in) :: n
  real(c_double), intent(in) :: x(n), y(n)
  real(c_double) :: r
  r = sum(x * y)
end function dot

Hand-check x=[1,2,3], y=[4,5,6]: $1\cdot4 + 2\cdot5 + 3\cdot6 = 4+10+18 = 32$.

15.25 Since Chapter 6 you have written intent on every dummy argument as a safety feature: it lets the compiler guarantee a routine cannot write to an argument it declared intent(in). In Chapter 15 the same declarations do a second job: f2py reads them to build the Python interfaceintent(in) becomes a Python argument, intent(out) becomes a return value, intent(inout) becomes an in-place argument. So a habit you adopted purely for correctness turns out to be precisely the metadata f2py needs to generate a clean Python signature. Careful Fortran wraps itself; sloppy Fortran (missing intents) forces f2py to guess.


Chapter 16 — The Fortran Ecosystem

Solutions to the daggered (†) and odd-numbered problems. Hands-on fpm and research problems (16.12, 16.14, 16.18) have setup-dependent answers, so a model response is given. Compilable calculations for 16.20 and 16.24 are in code/exercise-solutions.f90.

16.1 LAPACK — solves dense linear-algebra problems ($A\mathbf{x}=\mathbf{b}$, eigenvalues, SVD), in Fortran, on top of BLAS. BLAS — standardized low-level vector/matrix kernels (Levels 1/2/3) that the higher libraries call. fpm — the Fortran Package Manager: builds projects and manages dependencies from an fpm.toml. stdlib — the community standard library (stats, sorting, strings, I/O, math). FORD — generates HTML documentation from source and doc comments. pFUnit — a (parallel-capable) unit-testing framework for Fortran.

16.3 The three BLAS levels: Level 1 operates on vectors (e.g. axpy, dot product), Level 2 on a matrix and a vector (e.g. gemv), Level 3 on two matrices (e.g. gemm). A matrix-matrix multiply is Level 3.

16.5 fpm build (compile everything in dependency order), fpm run (build if needed, then run the app/ executable), fpm test (build if needed, then run the test/ programs).

16.6 (a) LAPACK (dgesv); (b) FFTW; (c) NetCDF (or HDF5); (d) MPI; (e) fortls (the Fortran Language Server); (f) FORD.

16.7 Separation of concerns. LAPACK is written once, portably, against a fixed BLAS interface — that portable part is authored by the LAPACK developers (and your own code is written the same way). The fast part — a BLAS implementation tuned to a specific CPU's caches and vector units — is written separately by hardware vendors and specialists (OpenBLAS, Intel MKL, BLIS). Because the interface is fixed, portable LAPACK automatically runs at the speed of whichever tuned BLAS is installed. Portability and speed are delivered by different people through one stable interface.

16.9 A Level-3 operation does $O(n^3)$ flops on $O(n^2)$ data, so each number loaded from memory is reused $O(n)$ times — high arithmetic intensity. That reuse keeps the arithmetic units busy from cache, so a tuned Level-3 kernel runs near the processor's peak. Level-1 operations do one flop per number moved and are starved by memory bandwidth. Restructuring LAPACK around Level-3 calls is therefore what lets it reach the hardware's peak — the whole reason for the LINPACK/EISPACK → LAPACK redesign.

16.11 "NumPy isn't faster than Fortran — for linear algebra it is Fortran: numpy.linalg.solve calls LAPACK's dgesv, which calls a BLAS dgemm, all compiled from Fortran or against its interface."

16.13 A git dependency must be an inline table with a git key, not a bare string. A bare string value is read as a version requirement from a registry, so fpm looks for a package versioned literally "https://…" and fails. The fix is one pair of braces: stdlib = { git = "https://github.com/fortran-lang/stdlib" }.

16.15 The first line tracks the latest commit of a moving repository — convenient while experimenting, but your build can change under you. The second pins a specific tag (v0.7.0), so every build fetches exactly that revision — reproducible. Use the unpinned form to explore; pin a tag for anything you intend to publish or share (the Ch. 37 reproducibility discipline). (The tag string is illustrative; confirm a real one from stdlib's releases.)

16.17 The line use stdlib_stats, only: mean requires the compiled stdlib_stats module (its .mod file) and its object code to be present at compile and link time. A bare gfortran file.f90 command knows nothing about stdlib, so it fails. Under fpm, the stdlib dependency in fpm.toml tells fpm build to fetch, build, and link stdlib first, after which the use resolves — the same module-availability rule from Ch. 8, now across a package boundary.

16.19 LFortran is a modern LLVM-based Fortran compiler that can also run Fortran interactively, statement by statement (like a Python REPL), and it powers the in-browser fortran-lang playground — something gfortran cannot do. It is still maturing, not yet a drop-in replacement for gfortran on large production codes, so today it is best used to explore and teach; build production code with an established compiler. (More in Chapter 39.)

16.20 A general $n\times n$ multiply ≈ $2n^3$ flops. For $n=2000$: $2\times(2000)^3 = 2\times 8.0\times10^9 = 1.6\times10^{10}$ flops. At an illustrative $40$ Gflop/s: $1.6\times10^{10} / 4.0\times10^{10} = 0.4$ s. At a reference $2$ Gflop/s: $1.6\times10^{10} / 2.0\times10^{9} = 8$ s — about a 20× difference from the tuned BLAS alone. (See code/exercise-solutions.f90.)

16.21 Matrix-matrix: $O(n^3)$ flops / $O(n^2)$ numbers moved $= O(n)$ flops per number — grows with $n$. Vector $y \leftarrow y + ax$: $O(n)$ flops / $O(n)$ numbers $= O(1)$ flop per number — constant, and small. The matrix-matrix operation reuses each loaded number many times, so it is compute-bound and can run near peak; the vector operation touches each number about once, so it is memory-bandwidth-bound and cannot.

16.22 $1000\times1000 = 10^6$ cells; at $8$ bytes per real(dp) that is $8\times10^6$ bytes $= 8$ MB per array. Three arrays (current, next, scratch) $\approx$ 24 MB. (Comfortably in cache-adjacent RAM; the point is that the footprint scales with the number of full-field copies you keep — a reason to minimize them.)

16.23 Converting costs $4$ h $= 240$ min; it saves $20$ min/week. Break-even: $240 / 20 = $ 12 weeks. After three months the switch has paid for itself, and every week after is pure savings — before counting the bugs avoided by never mis-ordering a build again.

16.24 Port (population standard deviation), intrinsics only:

mean_y = sum(y) / real(size(y), dp)
std_y  = sqrt( sum((y - mean_y)**2) / real(size(y), dp) )

For [1,2,3,4,5]: mean $=3$; squared deviations $[4,1,0,1,4]$ sum $10$; variance $10/5=2$; std $=\sqrt2\approx 1.41$. In a real project the whole body is std_y = std(y, corrected=.false.) from stdlib_stats. (See code/exercise-solutions.f90.)

16.25 The C core (netcdf-c) is installed, but the Fortran interface library (netcdf-fortran) is missing — the Fortran API symbols like nf90_open live there. Install/link netcdf-fortran in addition to the C library. (The general trap from §16.2: a C-cored library's Fortran bindings are a separate package.)

16.26 Link order. On many linkers, a library must appear on the command line after the objects that use it, because the linker resolves symbols left to right and pulls in only the library members needed by what came before. Put the source/objects first: gfortran solve.f90 -o solve -llapack -lblas (and -llapack before -lblas, since LAPACK depends on BLAS).

16.27 The argument descriptions use a plain ! comment, which FORD ignores. FORD documents an argument with the !! marker (documenting the entity that precedes it). Fix each line:

real(dp), intent(in) :: x(:)   !! the input vector
real(dp), intent(in) :: a      !! the scale factor

(And a !> line before the function would document the function itself.) The markers are still comments, so the code compiles exactly as before.

16.28 (model) Layout and manifest:

heat-solver/
├── fpm.toml
├── app/
│   └── main.f90          program heat (driver)
├── src/
│   ├── kinds.f90         module kinds (dp)
│   ├── heat_types.f90    module heat_types (field_t)
│   ├── heat_solver.f90   module heat_solver (the update)
│   └── heat_io.f90       module heat_io (namelist read, field write)
└── test/
    └── test_step.f90     a regression check
[dependencies]
stdlib = { git = "https://github.com/fortran-lang/stdlib", tag = "v0.7.0" }

Programs go in app/, the modules (which use one another) in src/, tests in test/; fpm derives the compile order from the use graph.

16.29 Converting to fpm eliminates the hand-written compile/link script and the manual object list (kinds.o heat_solver.o heat_io.o) — fpm discovers the sources and links them for you. The order of the first three -c commands is a hazard because heat_solver and heat_io use kinds, so kinds.mod must be produced (compile kinds.f90) before they compile; any code that uses another module imposes such an ordering (Ch. 8's .mod rule). With a hand script you must re-derive that order by hand every time a use dependency changes — exactly the bookkeeping fpm computes automatically from the module graph.

16.30 (a) A module must be compiled before any module that uses it, because compiling a module produces the .mod file the user needs — Ch. 8's compilation-order rule; fpm computes that order. (b) Column-major memory layout (Ch. 5): both matmul and a BLAS gemm are fast because they stream through contiguous memory in the order Fortran stores it. (c) error stop (Ch. 13) — it terminates with a non-zero exit status that fpm test (or a driving script/CI) can detect as a failure, unlike a plain stop.


Chapter 17 — Reading FORTRAN 77

Solutions to the daggered (†) and odd-numbered problems. Compilable modernizations (17.20, 17.22, 17.23, 17.24, 17.28) are also worked in code/exercise-solutions.f90. Where a problem asks for a rewrite in isolation, any clean, correct, behavior-preserving modern version is acceptable.

17.1 Columns 1–5 hold an optional statement label (a number); column 6 is the continuation marker (any nonblank there means "continue the previous statement"); columns 7–72 hold the statement body; and a C (or *) in column 1 makes the entire line a comment. (Columns 73–80 are ignored.)

17.3 -std=legacy tells gfortran to accept obsolete and deleted features (arithmetic IF, Hollerith, real DO variables, and so on) rather than rejecting them, and — together with the .f/.for extension — to read the source as fixed-form. You need it whenever you compile inherited FORTRAN 77 that uses constructs the modern standard no longer permits.

17.5 A named COMMON block is shared across several program units, so initializing it with DATA inside one ordinary routine would make it ambiguous which unit "owns" the initialization (and two routines could try to initialize it differently). The standard confines the initialization of named COMMON to a single dedicated BLOCK DATA unit, guaranteeing exactly one authoritative initializer.

17.7 The C is in column 4, not column 1. In fixed-form only a C or * in column 1 marks a comment; here the C sits in the statement field, so the compiler tries to parse the line as a (malformed) statement. Fix: move the C to column 1 — or, in modern free-form, use an ! comment.

17.9 A fixed-form statement must occupy columns 7–72. Shifting the code left so it begins in column 1 pushes its first six characters into the reserved fields: characters land in the label field (columns 1–5), which is only valid for a numeric label, and one lands in column 6, where a nonblank means "continuation." The compiler therefore sees malformed labels and spurious continuation lines rather than the statements you intended.

17.11 Yes — both declarations list (real, real, integer) in the same three positions, so association by position gives AX, BY, NM. B in the first routine corresponds to Y in the second (both occupy the second slot). The compiler cannot catch a mismatch because each routine is compiled separately and COMMON associates by storage position, never by name — nothing ever compares the two declarations side by side. (This is exactly why a re-ordered declaration corrupts data silently.)

17.13 Two hazards: (1) non-portability — overlaying a REAL on an INTEGER assumes they have the same size and representation, which is not guaranteed across machines, so the same code can corrupt memory elsewhere; (2) silent reinterpretation — reading RBUF after IBUF was written (or vice versa) yields the bit pattern reinterpreted as the other type, a value with no clear meaning and no syntactic warning (and the alias also blocks the optimizer). The modern, safe way to say "reinterpret these bits as another type" is the intrinsic transfer(source, mold).

17.15

select case (mode)
case (1)
  ! ... (was the block at label 100)
case (2)
  ! ... (was 200)
case (3)
  ! ... (was 300)
case default
  error stop 'invalid mode'      ! the computed GOTO would silently fall through
end select

select case adds a default for out-of-range values and forbids accidental fall-through — safety the computed GOTO lacked.

17.17 HYP(A,B) = SQRT(A*A + B*B) computes the hypotenuse / Euclidean length $\sqrt{a^2 + b^2}$. Modernized:

pure function hyp(a, b) result(h)
  real(dp), intent(in) :: a, b
  real(dp) :: h
  h = sqrt(a*a + b*b)            ! or the more overflow-robust intrinsic hypot(a, b)
end function hyp

17.19

module mesh
  use, intrinsic :: iso_fortran_env, only: dp => real64
  implicit none
  private
  public :: u, npts
  real(dp) :: u(100)             ! one typed, checked, named declaration
  integer  :: npts
end module mesh

Better still, make u allocatable and size it to npts at run time, since the fixed 100 was only a FORTRAN 77 limitation. Every routine reaches the state with use mesh, only: u, npts — by name, checked.

17.21 No BLOCK DATA is needed at all; the initialization moves next to the declaration in a module:

module cfg
  use, intrinsic :: iso_fortran_env, only: dp => real64
  implicit none
  real(dp), parameter :: dt = 0.01_dp
  integer,  parameter :: nstep = 500
end module cfg

(If dt/nstep must be variable rather than parameter, drop parameter and keep the = … initializer — a module variable is implicitly save, so the initializer runs once, exactly as BLOCK DATA did.)

17.23 The whole EQUIVALENCE-plus-loop collapses to a single intrinsic call:

peak = maxval(t)                 ! t is the 2-D field; no aliasing, no manual scan

maxval sees the real 2-D array directly, so there is no need to alias it as a 1-D vector at all. (Worked on the hot-edge field in code/exercise-solutions.f90100.00.)

17.25 Two implicit-typing bugs. (1) NSUM begins with N, so it is INTEGER; NSUM = NSUM + A(I) converts the real sum to an integer and truncates it every iteration, and for fractional A(I) < 1 the accumulator never leaves 0. (2) AV = NSUM / N divides two integers, so it is integer division, truncating again. The routine therefore prints 0.0 every time. Fix: give the accumulator a real type (add implicit none and real(dp) :: total, or just rename it to a non-IN name) and force real division:

real(dp) :: total, av
total = 0.0_dp
do i = 1, n
  total = total + a(i)
end do
av = total / real(n, dp)

17.27 Each array is $N^2$ doubles at 8 bytes. At $N = 2000$: $2000^2 = 4\times10^6$ elements $\times\,8$ B $\approx 32$ MB per array, so T plus TNEW is $\approx 64$ MB — large but feasible. Work: each sweep does $O(N^2)$ cell updates, and unaccelerated Jacobi needs $\sim O(N^2)$ sweeps to converge, so total work is $\sim O(N^4)$. Doubling $N$ multiplies the work by $\sim 16$. That steep scaling is exactly why later parts reach for better methods (multigrid; a direct solve via LAPACK, Chapter 21) and for parallelism (Parts VII–VIII) — brute-force Jacobi on a fine grid is unaffordable.

17.29

converged = .false.
relax: do iter = 1, maxit                 ! the bounded do handles the iteration cap
  ! ... one Jacobi sweep, compute dmax ...
  if (dmax <= tol) then
    converged = .true.
    exit relax                            ! and exit handles convergence
  end if
end do relax

if (converged) then
  print '(a, i0, a)', 'converged in ', iter, ' iterations'
else
  print '(a)', 'iteration limit reached'
end if

A single named do iter = 1, maxit construct handles both exits: it falls out on its own when the count is exhausted, and exit relax leaves early on convergence. The post-loop if reports which one fired — cleaner than the legacy pair of IF (…) GO TO lines, and the flag makes the outcome explicit.


Chapter 18 — Modernizing Legacy Fortran

Solutions to the daggered (†) and odd-numbered problems. Computational solutions (18.18, 18.20, 18.22) are compilable in code/exercise-solutions.f90, each with a hand-computed expected output.

18.1 The eight steps, in order: (1) add implicit none; (2) convert fixed-form to free-form; (3) replace COMMON blocks with modules; (4) add intent to every dummy argument; (5) replace assumed-size array arguments with assumed-shape; (6) replace GOTO/arithmetic IF with structured control (do/exit/cycle/if/select case); (7) replace EQUIVALENCE with types, reshape/array intrinsics, or transfer; (8) add error handling (error stop, stat=, iostat=, input validation).

18.3 A rewrite replaces the code with new code and must re-earn correctness from scratch; a refactoring changes the form while preserving observable behavior, so the code stays correct throughout. For validated scientific code the distinction is decisive because the irreplaceable asset is the validated behavior (years of agreement with experiment), which a rewrite discards and a refactoring keeps. For a web front-end, "behavior" is cheaper to re-establish, so a rewrite carries less risk.

18.5 Modernize the COMMON /STATE/ U(MX), V(MX), NP block into a module:

module state
  implicit none
  integer, parameter :: dp = selected_real_kind(15, 307)
  integer, parameter :: mx = 100
  real(dp) :: u(mx), v(mx)
  integer  :: np
end module state

Each routine that shared the block now writes use state (optionally use state, only: u, v, np). Better still, as a later step, pass u, v, np as arguments and drop the global entirely.

18.7 Two modern replacements for AVG(TL,TR,TB,TA) = 0.25D0*(TL+TR+TB+TA):

  1. An internal procedure in the routine's contains section: fortran contains pure real(dp) function avg(tl, tr, tb, ta) real(dp), intent(in) :: tl, tr, tb, ta avg = 0.25_dp * (tl + tr + tb + ta) end function avg
  2. Inline the expression at the (single) point of use: tnew(i,j) = 0.25_dp * (t(i-1,j) + t(i+1,j) + t(i,j-1) + t(i,j+1)).

Prefer the internal procedure when the expression is used in several places or is complex enough to deserve a name and a test; prefer inlining when it is used once, as in PLATE — fewer moving parts.

18.9 Replace the whole EQUIVALENCE-and-loop block with one array intrinsic: gmax = maxval(g(1:nx, 1:ny)). No aliasing, no manual loop — ask the array directly.

18.10 The migration copied the legacy continue condition into the exit, so the loop exits exactly when it should continue. After the first sweep res > eps is true, so it exits immediately. The exit condition must be the logical negation of the continue condition: if (res <= eps .or. it >= itmax) exit.

18.11 The only: t clause imported t but not n, so the routine's references to n no longer name the module variable. Under implicit none this is a compile-time error ("n has no IMPLICIT type"), which is exactly how you find it — without implicit none the old implicit rule would silently create a fresh local integer n (value undefined) and corrupt the result at run time. Fix: use grid_data, only: t, n.

18.12 Not a valid refactoring. Writing the update back into t immediately means later points in the same sweep read already-updated neighbors — this is Gauss–Seidel, a different algorithm. It converges to the same steady state (the fixed point is the same) but through different iterates and in fewer sweeps, so the iteration count (and every intermediate field) changes. A refactoring must preserve behavior; changing Jacobi to Gauss–Seidel changes the numerics, so it belongs in the "deliberate change, validate to tolerance" category, not the "pure refactoring, bit-for-bit" one.

18.13 No — the reviewer misread a legitimate improvement as a regression. Promoting real to real(dp) shifts every value in its low-order digits by design; bit-for-bit agreement is neither expected nor wanted. They compared text; they should have parsed the numbers and compared within a tolerance justified by the code's validated accuracy. The migration is fine (indeed better); the test was wrong for this step.

18.14 You may demand bit-for-bit agreement when the change preserves the arithmetic — same operations, same order, same precision. Two arithmetic-preserving steps: adding implicit none; converting a COMMON block to a module (also: free-form conversion, GOTOdo, adding intent, assumed-shape). Two changes that break it: promoting single to double precision; reordering/reassociating a reduction (also enabling -ffast-math or FMA contraction).

18.15 Because output formatting is not arithmetic. Example: the legacy code prints the iteration count with WRITE (6,'(I5)') (padded to five columns: 25) and the modern code with print '(i0)' (25). A diff flags the line as changed even though the number 25 is identical. A numerical regression test parses 25 from each and sees they match. The same applies to D vs E exponents and trailing spaces.

18.16 The compiler and its optimization flags (and, through them, the target architecture). Identical source can produce different bits under different optimization levels (expression reassociation at -O3), under -ffast-math (which permits non-IEEE transformations), or when the compiler contracts a multiply-add into a fused fma. "Bit-for-bit reproducible" therefore names a specific build, which you must record.

18.17 Reference: capture the legacy PLATE output once (./plate-legacy > reference.txt). Tolerance: because every modernization step preserves the arithmetic, demand bit-for-bit — an absolute tolerance of zero on the interior field (parsing the numbers, ignoring the cosmetic iteration-count format). Failure: any interior temperature differs from the reference by more than zero (or, if you allow for a deliberate later precision change, by more than the small tolerance you then justify). A pass certifies you changed the engineering, not the science.

18.18 (code — code/exercise-solutions.f90.) A 3×3 grid has a single interior cell whose four neighbors are all boundary points: the hot top edge (100°) and three cold edges (0°). Its steady value is $(100 + 0 + 0 + 0)/4 = 25.0$°. The value is correct after the first sweep, but the convergence loop needs a second sweep to observe that the change has dropped to zero and stop — so the loop reports 2 iterations. Expected output line: 18.18: center = 25.00, iters = 2.

18.19 Track convergence explicitly and test after the loop:

logical :: converged
! ... inside the loop, before exit:  converged = (dmax <= tol)
! ... after the loop:
if (.not. converged) error stop 'relax: failed to converge within maxit sweeps'

Placing the error stop after the loop (rather than silently returning) turns a non-converged run into a loud, diagnosable failure. The message should name the routine and the cause (hit the iteration cap).

18.20 (code — code/exercise-solutions.f90.) The converged 4×4 interior is 37.5, 37.5 (upper) and 12.5, 12.5 (lower). The average is $(37.5 + 37.5 + 12.5 + 12.5)/4 = 100/4 = 25.0$°. Expected output line: 18.20: average interior = 25.00. (A neat sanity check: the average interior temperature equals the average of the four edge temperatures, $(100 + 0 + 0 + 0)/4 = 25$ — a property of the discrete Laplace solution.)

18.21 By linearity (superposition), write the solution with hot edge 100° and three edges $c$° as $T = c + S$, where $S$ solves the problem with hot edge $100 - c$ and three edges 0°. Since the $(100, 0)$ problem gives interior 37.5/12.5, the scaled $S$ gives $\tfrac{100-c}{100}$ of those. For $c = 10$: upper $= 10 + 0.9(37.5) = 43.75$°, lower $= 10 + 0.9(12.5) = 21.25$°. Check with the steady equations (hot 100, cold 10): $3a - b = 110$, $3b - a = 20 \Rightarrow 8b = 170$, $b = 21.25$, $a = 43.75$. (Raising all four edges by $c$ would instead shift the whole solution by exactly $c$.)

18.22 (code — code/exercise-solutions.f90.) If the change is 1.0 and halves each sweep, after $k$ sweeps it is $0.5^k$. Solve $0.5^k \le 10^{-9}$: $0.5^{29} = 1.86\times10^{-9}$ (too big), $0.5^{30} = 9.31\times10^{-10} \le 10^{-9}$. So 30 more sweeps. Expected output line: 18.22: sweeps to 1e-9 = 30.

18.23 Editing: $40 \text{ modules} \times 0.5 \text{ h} = 20 \text{ h} \approx 2.5$ person-days for the mechanical implicit none/module work. But the testing time dominates: every change must be rebuilt and compared against the reference, COMMON-to-module conversions must respect the dependency order (some modules block others), and any regression must be located and fixed — debugging a single subtle equivalence break can cost more than a day of editing. Real migrations are gated by verification and coordination, not by typing speed.

18.24 Roughly half of 25 is about 12–13 sweeps for Gauss–Seidel. You gave up the Jacobi property that every point in a sweep is computed from the old field only. Gauss–Seidel updates in place, so points computed later in a sweep depend on points computed earlier — a sequential data dependency within the sweep. That dependency is exactly what makes Gauss–Seidel harder to parallelize than Jacobi (whose sweep is embarrassingly parallel), which matters when you reach OpenMP/MPI in Part VIII. Faster convergence, harder parallelism — a real trade, not a free lunch.

18.25 (Ch. 8) The compiler can check that every user of a module refers to its variables by the same name, type, and shape (a use cannot silently disagree the way four hand-copied COMMON lines can), and it can enforce public/private access control. It also builds and checks explicit interfaces for module procedures, catching argument-mismatch errors that COMMON-plus-external-subroutine code cannot detect.

18.27 (Ch. 6) intent(in) tells the compiler the argument will not be written inside the routine, so it may keep the value in a register, avoid reloading it after intervening writes, and assume it is unchanged across the body. Combined with Fortran's assumption that distinct arguments do not alias (the no-aliasing advantage of Chapter 1), the compiler can reorder and vectorize aggressively because it knows a write through another argument cannot have changed this read-only one. intent is thus an optimization hint as well as a safety contract.


Chapter 19 — FORTRAN 77 to Modern Fortran

Solutions to the daggered (†) and odd-numbered problems. The computational ones (19.5, 19.9, 19.11, 19.13, 19.19) are also compilable in code/exercise-solutions.f90. Assume dp => real64 and implicit none.

19.1 (†) (a) COMMON /STATE/ X, Y, N → a module holding x, y, n (or, better, a derived type / passed arguments). (b) EQUIVALENCE (R, IR) to inspect a real's bits → the intrinsic transfer(r, mold). (c) statement function SQ(X) = X*X → an internal pure function sq(x). (d) GO TO (10,20,30), MODEselect case (mode) with a case default. (e) REAL A(*) → an assumed-shape dummy real(dp) :: a(:).

19.3 False. Adding implicit none is a correctness change, not merely a style one. Under implicit typing a mistyped variable name silently becomes a brand-new, garbage-valued variable and the program computes a wrong answer with no error. implicit none makes that mistyped name an undeclared symbol, so the compile fails and the bug is exposed. A change that can convert a silent wrong answer into a caught error is by definition capable of affecting correctness.

19.5 (†, code) The COMMON block becomes one module declaration; the SETGRD routine's job (assign the dimensions) is done directly, or behind a small setter:

module grid_mod
  implicit none
  integer :: nx = 0, ny = 0
end module grid_mod

program demo
  use grid_mod, only: nx, ny
  implicit none
  nx = 41
  ny = 41
  print '(a, i0)', 'cells = ', nx * ny     ! 41 * 41 = 1681
end program demo

Output: cells = 1681. There is now one authoritative declaration of nx, ny; no second routine can describe the block differently.

19.7 (†) The bug. SETGRD writes 200 into the first word of /GRID/ and 100 into the second. NCELLS declares the block as a single integer NCELL, so it reads only the first word — 200 — and calls it the cell count. It returns N = 200, not 200 × 100 = 20000. Symptom: a cell count that is wrong but not random (it equals NX), and that would change if the declarations were reordered or the routines linked differently. Why a module fixes it: with nx, ny declared once in a module and the cell count computed by a function nx*ny, there is no "first word of the block" for a second routine to misread — the mistake is unrepresentable. (This is the bug dissected in the Chapter 8 case study.)

19.9 (†, code) The structured loop keeps both clauses of the original exit test:

iter = 0
do
   iter = iter + 1
   call sweep(diff)
   if (diff <= tol .or. iter >= maxit) exit
end do

If you translate to do while (diff > tol) alone, two things break: (1) the iteration cap is gone, so a problem that never converges loops forever instead of stopping and reporting non-convergence; and (2) diff is tested before the first sweep, when it is still undefined, so the loop's behavior depends on garbage. The do ... exit form tests after computing diff and preserves the safety valve.

19.11 (†, code) The arithmetic IF becomes a block if:

if (resid < 0.0_dp) then
   sgn = -1.0_dp
else if (resid == 0.0_dp) then
   sgn = 0.0_dp
else
   sgn = 1.0_dp
end if

The fragile branch is the middle one, resid == 0.0_dp: a real residual almost never lands on exactly zero, so as written the branch is nearly dead code. If the "zero" case was meant to catch negligibly small residuals, replace the exact test with a tolerance: else if (abs(resid) < eps) then. Keep exact == 0.0_dp only when resid is a deliberately exact sentinel. (For a pure sign you could also use the sign intrinsic, but it has no distinct "exactly zero" result, which is precisely what the arithmetic IF was branching on.)

19.13 (†, code) The statement function becomes an internal pure function with declared types and an intent:

pure function avg4(tl, tr, tb, ta) result(m)
  real(dp), intent(in) :: tl, tr, tb, ta
  real(dp) :: m
  m = 0.25_dp * (tl + tr + tb + ta)
end function avg4

Why it must be translated: statement functions were declared obsolescent in Fortran 90 and removed from the current standard, so you cannot rely on the original compiling under -std=f2018. The internal function also gains declared argument types (the statement function inherited whatever implicit typing gave TLTA), an explicit interface, and the pure promise that lets the compiler inline it.

19.15 (†) Inferring the data flow from the body: SETEDGE only writes T (the top and side edges) and never reads it — but it writes only the edges, leaving the interior untouched, so the caller's interior must survive the call. That rules out intent(out) (which would make the whole array undefined on entry, destroying the interior) and requires intent(inout). THOT and TCOLD are read-only → intent(in). NX, NY disappear once the array is assumed-shape. Modern header:

subroutine set_edges(t, t_hot, t_cold)
  real(dp), intent(inout) :: t(:,:)     ! only edges written; interior preserved -> inout, not out
  real(dp), intent(in)    :: t_hot, t_cold
  integer :: mx, my
  mx = size(t, 1);  my = size(t, 2)
  t(:, my) = t_hot                        ! top edge
  t(1, :)  = t_cold;  t(mx, :) = t_cold   ! sides
  ! (add t(:,1) = t_cold for the bottom if the physics wants all three cold)
end subroutine set_edges

19.16 (†) Free-form: the C comment becomes !; the column-6 continuation becomes a trailing &; the label was unreferenced, so it is dropped:

! ACCUMULATE THE WEIGHTED SUM
s = w1*x1 + w2*x2 + &
    w3*x3 + w4*x4

19.17 Integer variables (by the IN rule): K, N. Real variables: SUM (starts S), A, XBAR. With implicit none and explicit declarations (and renaming sumtotal, since sum shadows the intrinsic):

implicit none
real(dp) :: total, xbar, a(n)   ! or assumed-shape a(:)
integer  :: k, n
total = 0.0_dp
do k = 1, n
   total = total + a(k)
end do
xbar = total / real(n, dp)      ! N was integer; real division was already intended (total is real)

Note the original XBAR = SUM / N was real-over-integer mixed-mode: N was promoted to real, so this was not the integer-division trap. Writing real(n, dp) makes the promotion explicit and portable.

19.18 (†) The bug is the typo TOATL for TOTAL on the accumulation line. Under implicit typing, TOATL is a new, implicitly-REAL variable (starting T), separate from TOTAL; the loop adds X(I) into TOATL while TOTAL stays at its initial 0.0, so the program prints 0.0 (or a garbage value) regardless of the data. Under implicit none, TOATL is an undeclared name, so the program will not compile — the compiler points straight at the typo. This is the canonical argument for implicit none: it converts a silent wrong answer into a caught compile error.

19.19 (†, code) The modern edge-setter uses assumed-shape, intent, and array-section assignments (no loops); see code/exercise-solutions.f90:

subroutine set_edges(t, t_hot, t_cold)
  real(dp), intent(inout) :: t(:,:)
  real(dp), intent(in)    :: t_hot, t_cold
  integer :: n
  n = size(t, 1)
  t(:, 1) = t_cold        ! left edge cold
  t(:, n) = t_cold        ! right edge cold
  t(n, :) = t_cold        ! bottom edge cold
  t(1, :) = t_hot         ! top edge (row 1) hot, written last
end subroutine set_edges

A cold corner such as t(n,1) is set to t_cold by both t(:,1) and t(n,:), so it reads 0.00 (the hot edge t(1,:) never touches row n). The NX, NY arguments of the legacy SETEDGE are gone (the assumed-shape array carries its extent), and intent(inout) — not intent(out) — documents and enforces that the routine overwrites only the boundary while preserving the interior.

19.21 (†) The reporting section, modernized — inline formats, an assumed-shape row, and no sequence-association trick:

print '(a, i0, a, i0)', 'grid: ', nx, ' x ', ny
print '(a, f8.2)',      'center temperature = ', t(ic, jc)
call print_row(t(:, jc))          ! pass the array SECTION; print_row takes row(:)

with

subroutine print_row(row)
  real(dp), intent(in) :: row(:)
  write(*, '(*(f6.1))') row
end subroutine print_row

The legacy CALL PRTROW(T(1,JC), NX) relied on sequence association — passing the address of a column's first element plus a hand-carried count. Passing the section t(:, jc) to an assumed-shape dummy is the modern equivalent: same values, but the extent travels with the array. (One nuance: the legacy I3 edit descriptor pads NX to width 3 while i0 is minimal-width; if byte-identical text matters for a diff, use '(a, i3, a, i3)'. The printed numbers are identical either way.)

19.23 (†) COMMON is global, mutable, shared state, and parallelism is fundamentally about controlling what is shared and what is private — which you can only do for named variables and arguments, not for a COMMON slab. Concretely: under OpenMP, a COMMON block is shared by every thread, so two threads updating it race on the same storage with no way to mark parts private cleanly (the historical threadprivate directive is a workaround, not a fix). Under coarrays or MPI, each image/process gets its own copy of the COMMON block, so it cannot serve as the shared field you decompose and halo-exchange. In both models you must first lift the state out of COMMON — into a module, and better into arguments or a derived type — so the parallel runtime can reason about ownership. Hence COMMON → module is a prerequisite for parallelization, not a cosmetic tidy-up.

19.25 (†) The statement function must be translated to compile under -std=f2018: it is removed from the current standard, so a strict modern compile cannot be relied upon to accept it. The assumed-size array (a(*)) is still legal Fortran, so translating it to assumed-shape (a(:)) is advisable — it restores size, whole-array operations, and bounds checking, and it is clearer — but it is not required to compile. The distinction is exactly the "Status" column of §19.5: removed forces the translation, legal-but-superseded merely recommends it.

19.27 (†) The literal 0.1 is a default (single-precision) real constant. Assigning it to a real(dp) variable computes the single-precision approximation of 0.1 — about 0.10000000149 — and then widens that to double, so alpha holds the single-precision value's error (~1.5e-9), not the double-precision value closest to 0.1. In a long relaxation this small bias can accumulate. The fix is to write the literal with the kind suffix, alpha = 0.1_dp, so the constant is formed in double precision from the start. (The general rule from the style bible: every real literal in numerical code carries its kind — 0.1_dp, 1.0_dp, 0.25_dp. Full treatment in Chapter 20.)


Chapter 20 — Floating-Point Arithmetic

Solutions to the daggered (†) and odd-numbered problems. Computational solutions are also in code/exercise-solutions.f90. Processor-dependent trailing digits are noted where they occur.

20.1 IEEE double precision (binary64) uses 64 bits: 1 sign, 11 exponent, and 52 stored fraction bits. Because a normalized number's leading 1 is implicit ("hidden bit"), the significand is effectively 53 bits, which is $53 \times \log_{10}2 \approx 15.95$ — about 15–16 significant decimal digits.

20.3 In base 2, one-tenth is a repeating fraction: $0.1_{10} = 0.0001100110011\ldots_2$, the 1100 block repeating forever, just as $1/3$ repeats in base 10. A finite 53-bit significand must round it, so 0.1_dp stores $0.1000000000000000055511\ldots$ — slightly too large. Any decimal whose denominator (in lowest terms) has a prime factor other than 2 is non-terminating in binary: 0.2, 0.3, and 0.7 all fail. A decimal is exact only if it is a sum of powers of two — e.g. 0.5, 0.25, 0.75, 0.125 are all exact.

20.5 if (x == 0.3_dp) asks whether a computed x landed on the exact same grid point as the stored 0.3_dp, which rounding almost never arranges (recall 0.1_dp + 0.2_dp is not the stored 0.3_dp). Use a tolerance: if (abs(x - 0.3_dp) < tol). Choose tol scaled to the magnitudes — an absolute tolerance like 1.0e-12_dp for values near 1, or a relative one, abs(x - 0.3_dp) <= tol*abs(0.3_dp), when the magnitude varies. A few ULPs (tol = 4*spacing(x)) is a principled default.

20.6 Prints F. 0.1_dp, 0.2_dp, and 0.3_dp are each rounded to the grid; the two rounding errors in 0.1 and 0.2 are both positive and add, pushing the sum to 0.30000000000000004…, exactly one ULP ($2^{-54}$) above the stored 0.3_dp. The two are different grid points, so == is false.

20.7 Prints 0.0. At $10^{16}$ one ULP is $2$ (spacing(1.0e16_dp) = 2), so 1.0e16_dp + 1.0_dp rounds back to 1.0e16_dp (the 1 is a tie that rounds to the even neighbour, which is $10^{16}$). Subtracting 1.0e16_dp then gives exactly 0.0 — the 1.0 was absorbed.

20.8 x = 0.0/0.0 is a NaN: ieee_is_nan(x) is .true.. y = 1.0/0.0 is +Inf: ieee_is_nan(y) is .false. and ieee_is_finite(y) is .false. (Inf is neither a NaN nor finite). So x is the NaN and y is the Inf.

20.9 spacing(x) = 2^(exponent(x) - 53). spacing(2.0_dp): exponent(2.0) = 2, so $2^{2-53} = 2^{-51} \approx 4.44\times10^{-16}$ — twice `epsilon`, because in $[2,4)$ the numbers are twice as far apart as in $[1,2)$. spacing(1024.0_dp): $1024 = 2^{10}$, exponent = 11, so $2^{11-53} = 2^{-42} \approx 2.27\times10^{-13}$. The spacing doubles each time the magnitude crosses a power of two.

20.10 Positive. (0.1_dp + 0.2_dp) is one ULP above 0.3_dp, so the difference is $+2^{-54} \approx +5.55\times10^{-17}$.

20.11 0.1 is not representable, so x accumulates rounding error and, after ten additions, equals 0.9999999999999999…, never exactly 1.0 — the x /= 1.0_dp test stays true forever. Fixes: (a) count with an integer and derive x: do i = 1, 10; x = real(i, dp)*0.1_dp; end do; or (b) test with a tolerance: do while (x < 1.0_dp - 0.5_dp*spacing(1.0_dp)). Never drive a loop's termination off exact equality of an accumulating real.

20.13 Convergence makes abs(temp_new - temp_old) small, but rarely exactly 0.0 — and it may oscillate in the last bit, so == 0.0_dp almost never fires (or fires only by luck). Use a tolerance: if (abs(temp_new - temp_old) < tol) exit, with tol absolute (e.g. 1.0e-10_dp) or relative (< tol*abs(temp_new)) to the temperature scale. A relative test is more robust across problem magnitudes.

20.15 np.finfo(np.float64).epsepsilon(1.0_dp) (both $2^{-52}$); .maxhuge(1.0_dp); .tinytiny(1.0_dp) (the smallest normal positive number). The correspondence is exact because np.float64 is IEEE binary64, the same format as real(dp).

20.17 epsepsilon(1.0_dp); realmaxhuge(1.0_dp); realmintiny(1.0_dp). The subtle difference: MATLAB's bare eps equals $2^{-52}$ (= Fortran epsilon), but MATLAB's eps(x) returns the spacing at x — the analogue of Fortran's spacing(x), not epsilon. So MATLAB overloads one name for what Fortran splits into epsilon (at 1.0) and spacing (at any x).

20.18 Multiply by the conjugate: $$\sqrt{x+1} - \sqrt{x} = \frac{(\sqrt{x+1}-\sqrt{x})(\sqrt{x+1}+\sqrt{x})}{\sqrt{x+1}+\sqrt{x}} = \frac{1}{\sqrt{x+1}+\sqrt{x}}.$$ For large x the two roots are nearly equal, so the original subtraction is catastrophic cancellation; the rewritten form replaces it with an addition in the denominator — no cancellation, accurate for all x ≥ 0.

20.20 With $a=1$, $b=-10^{8}$, $c=1$: the discriminant is $b^2 - 4ac = 10^{16} - 4 \approx 10^{16}$, so $\sqrt{\cdot} \approx 10^{8}$, very close to $|b|$. The large root uses the addition and is safe: $x_1 = \frac{-b + \sqrt{b^2-4ac}}{2a} \approx \frac{10^{8}+10^{8}}{2} = 10^{8}$. The small root computed as $\frac{-b - \sqrt{\cdot}}{2a}$ subtracts two near-equal $\sim 10^{8}$ values — catastrophic. Compute it stably from $x_1 x_2 = c/a$: $x_2 = \dfrac{c}{a\,x_1} = \dfrac{1}{10^{8}} = 10^{-8}$. Roots: $x_1 \approx 1.0\times10^{8}$, $x_2 \approx 1.0\times10^{-8}$.

20.21 The problem is well-conditioned: as $x \to 0$, $(e^x-1)/x \to 1$ smoothly, and a small change in x changes the answer only slightly. The naive algorithm is unstable: exp(x) - 1 subtracts two near-equal values (exp(x) $\approx 1$) — catastrophic cancellation. Fortran 2018 has no standard expm1 intrinsic, so either call a C expm1 via iso_c_binding (Chapter 14) or use a Taylor series for small x: $(e^x-1)/x \approx 1 + x/2 + x^2/6 + \dots$, which has no subtraction.

20.22 Worst case (errors coherent): $N u = 10^{9} \times 2^{-53} \approx 10^{9} \times 1.11\times10^{-16} = 1.11\times10^{-7}$ — about 7 digits still clean. Typical (random walk): $\sqrt{N}\,u = \sqrt{10^{9}} \times 1.11\times10^{-16} \approx 3.16\times10^{4} \times 1.11\times10^{-16} \approx 3.5\times10^{-12}$ — about 12 digits clean. The random-walk estimate is usually realistic because rounding errors have mixed signs and partly cancel; the worst case assumes an adversarial alignment that rarely occurs. Either way, double precision easily absorbs $10^{9}$ operations.

20.23 Single precision: $u = 2^{-24} \approx 5.96\times10^{-8}$, so $uN = 5.96\times10^{-8} \times 10^{6} = 5.96\times10^{-2}$ — about a 6% error, roughly *one* correct digit. Hopeless for 12. Double: $u = 2^{-53} \approx 1.11\times10^{-16}$, $uN = 1.11\times10^{-10}$ — about 10 clean digits in the pessimistic bound, and $\sqrt{N}u \approx 1.11\times10^{-13}$ (about 13 digits) typically. Double suffices; single does not.

20.24 Exactly $2^{52} = 4{,}503{,}599{,}627{,}370{,}496$. In $[1, 2)$ the exponent is fixed (all these numbers are $1.f \times 2^{0}$), and the 52-bit fraction f takes every value from 000…0 to 111…1 — that is $2^{52}$ distinct significands, hence $2^{52}$ doubles, evenly spaced $2^{-52}$ apart.

20.25 $10^{8}$ elements. Single: $10^{8} \times 4 = 4\times10^{8}$ bytes $\approx$ 400 MB; double: $10^{8} \times 8 \approx$ 800 MB. A bandwidth-bound loop's time is set by bytes moved through memory, so halving the bytes can nearly halve the time — and on top of that, a cache line and a SIMD register each hold twice as many singles, further favouring single when its ~7-digit accuracy is enough.

20.26 Guard the requested kind at program start (a negative return means "no kind satisfies this request"):

module kinds
  implicit none
  private
  public :: dp
  integer, parameter :: dp = selected_real_kind(15, 307)
end module kinds
! ... in the driver, before any real work:
use kinds, only: dp
if (dp < 0) error stop "kinds: no real kind provides 15 digits / 1e307 range on this compiler"

The check belongs at the top of the driver (or in an initialization routine) so the program fails loudly and immediately on an inadequate platform rather than silently mis-typing every real.

20.28 $dt \sim dx^2/(4\alpha) = (10^{-3})^2 / (4\times10^{-4}) = 10^{-6}/(4\times10^{-4}) = 2.5\times 10^{-3}$ s. Steps for a 1-second run: $N = 1 / dt = 400$. Worst-case accumulated round-off: $N u = 400 \times 1.11\times10^{-16} \approx 4.4\times10^{-14}$ — about 13 clean digits. No, you should not worry: in double precision the round-off is dozens of orders of magnitude below any physical or discretization error. (Single would give $400 \times 5.96\times10^{-8} \approx 2.4\times10^{-5}$ — still small here, but the margin shrinks fast for longer runs.)

20.29 real(dp) :: x = 0.1 (no _dp) is a double mistake. First rounding: the literal 0.1 has no kind suffix, so it is a default realsingle precision — and 0.1 is rounded to ~7 digits. Second: that single-precision value is then widened to double for the assignment, which pads it with binary zeros but cannot recover the digits lost in the single rounding. So x holds the coarse single-precision 0.1, worse than 0.1_dp. Correct: real(dp) :: x = 0.1_dp.

20.31 -ffpe-trap=invalid traps the invalid operation 0.0/0.0 (which would produce a NaN); -ffpe-trap=zero traps the division by zero 1.0/0.0 (which would produce Inf). Combined: -ffpe-trap=invalid,zero. This halts the program at the exact offending line with a backtrace (Chapter 13), instead of letting the NaN/Inf spread silently and forcing you to scan a corrupted output file hours later with no clue where it began.


Chapter 21 — Linear Algebra and LAPACK

Solutions to the daggered (†) and odd-numbered problems. The four computational ones (21.9, 21.16, 21.26, 21.28) are also worked as runnable, LAPACK-linked code in code/exercise-solutions.f90. Design problems 21.16–21.18 admit variations; the model answers are below.

21.1 reshape([1,2,3,4,5,6], [2,3]) with no order= fills column by column: m(1,1)=1, m(2,1)=2, m(1,2)=3, m(2,2)=4, m(1,3)=5, m(2,3)=6. So m is the matrix $\begin{bmatrix}1&3&5\\2&4&6\end{bmatrix}$, and the printout is

   1   3   5
   2   4   6

It is not the matrix whose rows read 1 2 3 / 4 5 6 because column-major storage pours the list down the first column before moving to the second. To get the row-wise matrix, use order=[2,1].

21.3 $(A^{\mathsf T}A)^{\mathsf T} = A^{\mathsf T}(A^{\mathsf T})^{\mathsf T} = A^{\mathsf T}A$, so the product equals its own transpose — it is symmetric — for any a. For a = [[1,2],[3,4]], $A^{\mathsf T} = [[1,3],[2,4]]$ and $A^{\mathsf T}A = \begin{bmatrix}1\cdot1+3\cdot3 & 1\cdot2+3\cdot4\ 2\cdot1+4\cdot3 & 2\cdot2+4\cdot4\end{bmatrix} = \begin{bmatrix}10 & 14\ 14 & 20\end{bmatrix}$. The off-diagonals are both 14, so it equals its transpose.

21.5 (a) dgesv = double · general · solve. (b) sgetrf = single · general · triangular factorization (LU). (c) dsyev = double · symmetric · eigenvalues. (d) zheev = double-complex (z) · Hermitian · eigenvalues. (e) dgels = double · general · least squares.

21.7 Pass lda = 100 — the declared first dimension of a(100,100), not 5. Because the array is column-major, consecutive columns of the 5×5 block are still 100 elements apart in memory; lda is that column stride. Pass lda = 5 and LAPACK steps only 5 elements to reach what it thinks is the next column, landing in the middle of the first column of the big array — it reads and writes the wrong memory and returns a silently wrong result (no crash).

21.9 [[3,2],[1,2]] x = [7,5], determinant $6-2=4$, so $x_1 = (7\cdot2-2\cdot5)/4 = 1$ and $x_2 = (3\cdot5-7\cdot1)/4 = 2$: x = (1, 2). Three things NumPy did that your Fortran must now do explicitly: (1) it copied A so your matrix survived (dgesv overwrites a with the LU factors); (2) it allocated the pivot array for you (you must declare ipiv(n)); (3) it raised an exception on a singular matrix instead of returning info > 0 for you to check. See ex09_solve2x2 in the code.

21.11 MATLAB x = A \ b becomes call dgesv(n, 1, a, n, ipiv, b, n, info), after which the solution is in b. Two behavioral differences to account for: (1) dgesv overwrites both A and bA becomes its LU factors and b becomes x — whereas MATLAB leaves A untouched and returns a fresh x, so copy them if you need the originals; (2) your Fortran matrix is already column-major, the exact layout LAPACK expects, so no transposition or reordering is needed to hand it in — MATLAB hides its layout from you, Fortran does not.

21.12 Argument-order bug: the call is dgesv(3, 1, a, 3, b, ipiv, 3, info), which passes b in the fifth slot (where ipiv belongs) and ipiv in the sixth (where b belongs). The correct order is dgesv(n, nrhs, a, lda, ipiv, b, ldb, info). Because dgesv is an external routine with no interface, -Wall cannot catch it; it compiles, then at run time dgesv writes integer pivots into the real array b and the real solution into the integer ipiv — garbage both ways. Fix: call dgesv(3, 1, a, 3, ipiv, b, 3, info). (This is why you always check info and prefer a checked wrapper.)

21.13 After dgesv, a no longer holds the original matrix — it has been overwritten with the $L$ and $U$ factors. Computing matmul(a, b) multiplies the LU-packed array by the solution, which is meaningless, so the "residual" is nonsense even though info == 0. Fix: save a_orig = a (and b_orig = b) before the call, and compute matmul(a_orig, b) - b_orig.

21.14 reshape([2,3,1,4], [2,2]) with no order= fills column-major: a(1,1)=2, a(2,1)=3, a(1,2)=1, a(2,2)=4, i.e. the matrix $\begin{bmatrix}2&1\\3&4\end{bmatrix}$ — the transpose of the intended $\begin{bmatrix}2&3\\1&4\end{bmatrix}$. So it solves 2x + y = 8, 3x + 4y = 9 (answer $\approx(4.6, -1.2)$) instead of the intended system (answer $(1,2)$). One-token fix: add order=[2,1] to the reshape.

21.15 The missing step is checking info. After the call the code must test if (info /= 0) and handle failure (print a message, error stop, or otherwise refuse to use the result) before touching b. When info == 0, b holds the solution and is safe to print; when info /= 0, the solve failed and b is garbage — printing it is exactly the silent-wrong-answer bug the check prevents.

21.16 Tridiagonal a(n,n) with 1+2r on the diagonal and -r off it; for n=3, r=1 that is $\begin{bmatrix}3&-1&0\\-1&3&-1\\0&-1&3\end{bmatrix}$. With u^n = 0, a hot left edge u_L=1 and cold right edge u_R=0, the boundary terms give rhs = [1, 0, 0]. Solving (determinant 21) yields $\mathbf{u} = (8/21,\ 1/7,\ 1/21) = (0.3810,\ 0.1429,\ 0.0476)$ — a monotone decay from the hot edge toward the cold one, which is exactly the physically sensible profile. See ex16_implicit_n in the code.

21.18 In 2D each interior grid point couples to its four neighbors, so the unknowns are all nx·ny grid values flattened into one vector; the matrix relating them is therefore (nx·ny) × (nx·ny). It is sparse because each row has only about five nonzeros (the point itself plus its four neighbors) out of nx·ny columns — the rest are structural zeros. For nx = ny = 1000, A has $10^6$ rows; a dense representation would be $10^{12}$ entries ($8$ TB) and $O((10^6)^3)$ work, so dgesv is impossible — you must use a sparse solver (§21.6).

21.19 $\tfrac{2}{3}n^3$ for $n=1000$ is $\tfrac{2}{3}\times10^9 \approx 6.7\times10^8$ flops. At $10^{10}$ flop/s that is about $6.7\times10^{8}/10^{10} \approx 0.067$ s. A single matmul at the same n is $2n^3 = 2\times10^9$ flops $\to 0.20$ s. So an LU solve costs roughly a third of a matrix multiply — the $\tfrac{2}{3}$-vs-$2$ ratio — which is worth remembering: solving a system is cheaper than multiplying two matrices of the same size.

21.21 daxpy: $2n$ flops over $\sim 3n$ numbers $\Rightarrow \tfrac{2}{3}$ flops per number, a constant independent of n. dgemm: $2n^3$ flops over $\sim 3n^2$ numbers $\Rightarrow \tfrac{2}{3}n$ flops per number, which grows with n. Only Level 3, whose arithmetic-per-number climbs with problem size, does enough computation per memory access to hide memory latency and approach the processor's peak; Level-1 daxpy is stuck memory-bound at a fixed low ratio.

21.22 Column-major fill with the memory grain:

do j = 1, n            ! outer over columns
  do i = 1, n          ! inner over the FIRST index -> walks down a column, cache-friendly
    a(i, j) = ...
  end do
end do

This is the same layout reason LAPACK needs no transpose: LAPACK is Fortran and assumes first-index-fastest (column-major) storage, so a matrix you built in Fortran is already in the layout dgesv reads — no copy or transpose required at the boundary.

21.23 LAPACK depends on the BLAS. Because a library must be listed after the code that uses it, and LAPACK calls the BLAS, list -llapack before -lblas. In an fpm project you declare the dependency once in fpm.toml rather than typing flags — for example a link = ["lapack", "blas"] entry under [build] — and fpm build supplies the linker flags for you.

21.24 $\kappa(A) \approx 10^8$ costs about 8 decimal digits; double precision starts with ~16, so you can expect roughly 8 correct significant digits in x. A small residual does not by itself guarantee accuracy: for an ill-conditioned matrix the solution error is bounded by roughly $\kappa(A)$ times the relative residual, so a residual near the rounding floor can still hide a solution error eight orders of magnitude larger. Accuracy is governed by conditioning, not by residual size alone.

21.25 info > 0 from dgesv catches an exactly singular matrix — a pivot $U_{ii}$ that came out exactly zero. It does not catch mere ill-conditioning (a matrix that is nearly singular but technically invertible): there dgesv returns info = 0 and a full-looking but inaccurate answer. Estimate the conditioning yourself with dgecon, which returns a reciprocal condition number from the factorization; a tiny rcond warns you the "successful" solve is untrustworthy.

21.26 With $A = \begin{bmatrix}2&1&1\\1&3&2\\1&0&4\end{bmatrix}$ (determinant 19, nonsingular) and $\mathbf{b} = (3,2,9)$, the solution is $\mathbf{x} = (1, -1, 2)$ — substitute to check, e.g. row 3: $1\cdot1 + 0\cdot(-1) + 4\cdot2 = 9$. You must save a_orig and b_orig before the call because dgesv overwrites a with its LU factors and b with x; the residual maxval(abs(matmul(a_orig, x) - b_orig)) needs the originals and comes back at the rounding floor (well under $10^{-10}$: PASS). See ex26_dgesv_residual.

21.27 Almost never form A⁻¹ explicitly to compute x = matmul(A_inv, b), for two reasons: (1) it is more expensive — computing the inverse costs roughly three times an LU solve — and (2) it is less accurate, because the explicit inverse accumulates extra rounding and A⁻¹b has a worse error bound than a direct solve. dgesv instead factors A once (LU with pivoting) and solves by forward/back substitution, never forming the inverse. If you genuinely need the inverse (rare — usually you only think you do), factor with dgetrf and then call dgetri.

21.28 The Vandermonde columns give normal equations $\begin{bmatrix}4&6\\6&14\end{bmatrix}\mathbf{c} = \begin{bmatrix}17\\37\end{bmatrix}$ (determinant 20), so $c_1 = (17\cdot14 - 6\cdot37)/20 = 0.8$ and $c_2 = (4\cdot37 - 6\cdot17)/20 = 2.3$: the fit is $y = 0.8 + 2.3x$. The normal equations are numerically inferior because forming $A^{\mathsf T}A$ squares the condition number ($\kappa(A^{\mathsf T}A) \approx \kappa(A)^2$), so they lose roughly twice as many digits to rounding as the problem inherently requires; dgels (QR) and the SVD solve the least-squares problem from $A$ directly and therefore see only $\kappa(A)$. See ex28_least_squares.


Chapter 22 — Numerical Integration and Differentiation

Solutions to the daggered (†) and odd-numbered problems. The computational ones (22.1, 22.4, 22.19, 22.20, 22.22) are also worked as runnable, hand-checked code in code/exercise-solutions.f90. All outputs were computed by hand; no code was executed.

22.1 Forward difference of $x^2$ at $x=3$, $h=0.5$: $(3.5^2 - 3^2)/0.5 = (12.25 - 9)/0.5 = 3.25/0.5 = 6.5$, printed as ` 6.5000`. Exact $f'(3) = 6$, so the error is $0.5$. The forward difference of any function with constant $f'' = 2$ has truncation error exactly $\tfrac{h}{2}f'' = \tfrac{h}{2}\cdot 2 = h = 0.5$ (for a quadratic the higher-order terms vanish, so the printed error equals the leading term to the digit). Yes, it matches. See ex01 in the code.

22.3 Simpson's rule, $\int_0^1 x^3\,dx$, $n=2$, $h=0.5$: $S = \tfrac{0.5}{3}[f(0) + 4f(0.5) + f(1)] = \tfrac16[0 + 4(0.125) + 1] = \tfrac16(1.5) = 0.25$, printed 0.25000000 — the exact value. Simpson is exact because its error term is proportional to $f^{(4)}$, and the fourth derivative of a cubic is identically zero, so Simpson integrates every polynomial up to degree 3 exactly.

22.5 Taylor-expand $f(x-h) = f(x) - h f'(x) + \tfrac{h^2}{2}f''(x) - \tfrac{h^3}{6}f'''(x) + \cdots$. Then $$ \frac{f(x) - f(x-h)}{h} = \frac{h f'(x) - \tfrac{h^2}{2}f''(x) + \cdots}{h} = f'(x) - \tfrac{h}{2}f''(x) + \cdots $$ The leading error term is $-\tfrac{h}{2}f''(x)$, proportional to $h$, so the backward difference is $O(h)$ — the same order as the forward difference, with the opposite-signed leading error.

22.7 Simpson, $\int_0^1 e^x\,dx$, $n=2$, $h=0.5$: $S = \tfrac{0.5}{3}[e^0 + 4e^{0.5} + e^1] = \tfrac16[1 + 4(1.64872) + 2.71828] = \tfrac16[1 + 6.59488 + 2.71828] = \tfrac16(10.31316) = 1.71886$. Exact $e - 1 = 1.71828$, so the error is $+0.00058$. Consistency check with the $O(h^4)$ error term $-\tfrac{(b-a)h^4}{180}f^{(4)}(\xi) = -\tfrac{(0.5)^4}{180}e^{\xi}$ for $\xi \in [0,1]$: this lies in $[-0.00094, -0.00035]$ in magnitude, and $0.00058$ falls squarely inside — yes, consistent with fourth order. (The measured error's sign is positive here because our rounded $e^{0.5}$ was rounded up; the exact Simpson error is negative but tiny.)

22.9 The weights are swapped. Simpson requires the odd interior nodes ($i = 1, 3, 5, \ldots$) to have weight 4 and the even ones weight 2, but the snippet tests mod(i,2) == 0 for the weight-4 branch, giving weight 4 to even nodes and 2 to odd. For $n = 2$ (only node $i=1$, odd) it wrongly applies weight 2: $\int_0^1 x^2$ comes out $\tfrac{0.5}{3}[0 + 2(0.25) + 1] = \tfrac16(1.5) = 0.25$ instead of the correct $\tfrac13$. Fix: change the test to if (mod(i,2) == 1) for the weight-4 branch (or swap the two assignments).

22.11 The endpoints are missing their factor of $\tfrac12$. The line s = f(a) + f(b) gives the two ends full weight, but the trapezoidal rule weights them by $\tfrac12$. Every result is therefore too large by $\tfrac12(f(a)+f(b))\cdot h$. Fix: s = 0.5_dp * (f(a) + f(b)). (This is the single most common trapezoid bug; the convergence test still shows $O(h^2)$, which is why it hides — only the constant is wrong, so watch the absolute value against a known integral, not just the ratio.)

22.13 The Fortran port is the simpson function of §22.2 / example-02. For $n = 10^7$ panels of a cheap arithmetic integrand, the win is closer to $50\times$ (Tier 2, illustrative). What determines it: the pure-Python loop pays interpreter overhead on every one of the $10^7$ iterations (bytecode dispatch, boxing of floats, a Python-level call to f), whereas the compiled Fortran loop is a tight machine-code sweep the compiler can even vectorize. If instead f were genuinely expensive and evaluated in Python on both sides, the speedup would shrink toward $1\times$, because both versions would be dominated by the same slow f. The rule: the loop overhead is what Fortran eliminates; the win is large exactly when that overhead, not f, dominates.

22.15 Ratios $3.9, 3.98, 4.0$ mean $2^p \approx 4$, so $p = 2$ — a second-order method (the central difference, the trapezoidal rule, or the three-point stencil). Ratios $15.8, 16.0, 16.1$ mean $2^p \approx 16$, so $p = 4$ — Simpson's rule.

22.17 Trapezoid error $\sim C_2 h^2 = C_2 (L/n)^2$; for a relative accuracy $10^{-6}$ this needs $n \sim \sqrt{C_2 L^2 / 10^{-6}} \approx 10^{3}\sqrt{C_2 L^2}$ — of order a thousand panels. Simpson error $\sim C_4 h^4 = C_4 (L/n)^4$; the same accuracy needs $n \sim (C_4 L^4 / 10^{-6})^{1/4} \approx 31.6\,(C_4 L^4)^{1/4}$ — of order thirty panels. Taking the error constants and interval as comparable, Simpson reaches $10^{-6}$ with roughly $30$ evaluations where the trapezoidal rule needs about $1000$ — a $30\times$ difference that grows as the accuracy demand tightens. This is the practical meaning of "two orders higher."

22.19 gauss3 (see ex19 in the code) uses nodes $0, \pm\sqrt{3/5}$ and weights $\tfrac89, \tfrac59, \tfrac59$, mapped to $[a,b]$. On $[-1,1]$ (map is the identity, Jacobian 1): for $x^4$, $\tfrac59(\sqrt{3/5})^4 + \tfrac89(0) + \tfrac59(\sqrt{3/5})^4 = \tfrac{10}{9}\cdot(3/5)^2 = \tfrac{10}{9}\cdot\tfrac{9}{25} = \tfrac{10}{25} = 0.4$, the exact $\int_{-1}^1 x^4 = \tfrac25$. It is exact because $x^4$ has degree $4 \le 2n-1 = 5$. (For $x^6$, degree $6 > 5$, it is not exact: it returns $\tfrac{10}{9}(3/5)^3 = 0.24$ versus the true $\tfrac27 \approx 0.2857$.)

22.21 measure_order takes a difference operator op (a procedure argument returning the approximation at a given x0 and h), a known exact value, a starting step h0, and a number of levels:

subroutine measure_order(op, x0, exact, h0, levels)
  procedure(diff_op) :: op
  real(dp), intent(in) :: x0, exact, h0
  integer,  intent(in) :: levels
  real(dp) :: h, err, err_prev
  integer  :: k
  h = h0; err_prev = 0.0_dp
  do k = 1, levels
    err = abs(op(x0, h) - exact)
    if (k > 1) print '(a, es10.3, a, f6.2, a, f5.2)', &
        'h=', h, '  err=', err, '  order~', log(err_prev/err)/log(2.0_dp)
    err_prev = err; h = h/2.0_dp
  end do
end subroutine

For a correct $O(h^2)$ stencil it prints order~ 2.00 at each level (ratio 4). A coding bug that made it accidentally first-order — e.g. a one-sided formula, or dividing by $h$ where $2h$ was meant — would print order~ 1.00 (ratio 2): the measured order drops, which is exactly how the test catches the bug that the naked eye would miss.

22.23 The abstract interface scalar_fn must be declared pure function because a pure procedure may only reference other pure procedures; the dummy f inherits its purity from the interface, so a pure integrator can legally call it. If you passed an actual integrand that was not pure (say one that printed, or modified a module variable), the compiler would reject the association with an error such as "Actual argument for 'f' must be PURE" — caught at compile time, before any wrong answer. This is intent-style safety extended to procedures.

22.25 The tridiagonal system with $-2$ on the diagonal and $1$ on the off-diagonals is solved in $O(n)$ by LAPACK's dgtsv (double / general tridiagonal / solve), which takes three short arrays for the sub-, main-, and super-diagonals. The dense dgesv is the wrong tool because it stores all $n^2$ entries and does $O(n^3)$ work, treating the matrix's many zeros as real computation — for a large grid that is both far slower and far more memory than necessary. (This is the "call the right specialist" judgement from Chapter 21 §21.6.)


Chapter 23 — Solutions to Selected Exercises (odd-numbered and † problems)

Numeric values hand-computed; the coding solutions are compilable in code/exercise-solutions.f90.

A1 † — Euler with h = 0.5

Two steps of $y' = y$ from $y(0) = 1$: $y_1 = 1 + 0.5(1) = 1.5$; $y_2 = 1.5 + 0.5(1.5) = 2.25$. The final error is $e - 2.25 = 0.468282$, versus the chapter's $h = 0.25$ error of $0.276876$ — about $1.69\times$ worse. Doubling the step roughly doubled the error, the signature of first order.

A3 † — one RK4 step, h = 0.5

$k_1 = f(0,1) = 1$; $k_2 = f(0.25,\ 1 + 0.25) = 1.25$; $k_3 = f(0.25,\ 1 + 0.25\cdot1.25) = 1.3125$; $k_4 = f(0.5,\ 1 + 0.5\cdot1.3125) = 1.65625$. Then $y(0.5) = 1 + \frac{0.5}{6}(1 + 2.5 + 2.625 + 1.65625) = 1 + \frac{0.5}{6}(7.78125) = 1.6484375$. Exact $e^{0.5} = 1.6487213$; error $0.000284$. One RK4 step is ~1000× more accurate than one Euler step.

A5 — RK4 order confirmation

Integrating $y' = y$ to $t = 1$: at $h = 0.5$ (2 steps) $y = 2.717346$, error $9.36\times10^{-4}$; at $h = 0.25$ (4 steps) $y = 2.718210$, error $7.19\times10^{-5}$. Error ratio $\approx 13$ — heading toward the $16 = 2^4$ of exact fourth-order behavior but not yet there, because $h = 0.5$ is a large step and the asymptotic ($h \to 0$) regime has not been reached. Halve again and the ratio tightens toward 16.

B1 † — logistic Euler port

$r = 1$, $K = 10$, $N_0 = 1$, $h = 0.5$. Step 1: $F = 1\cdot1\cdot(1 - 1/10) = 0.9$, so $N_1 = 1 + 0.5(0.9) = 1.45$. Step 2: $F = 1\cdot1.45\cdot(1 - 1.45/10) = 1.45\cdot0.855 = 1.23975$, so $N_2 = 1.45 + 0.5(1.23975) = 2.069875$. See logistic in exercise-solutions.f90. The Fortran reproduces the Python line for line because both express $N + h\,rN(1 - N/K)$.

B3 — MATLAB ode45 → Fortran structure

@f (the function handle) becomes a Fortran function matching the rhs_sys abstract interface, passed as a procedure(rhs_sys) :: f dummy argument. The span [0 10] becomes explicit t_start/t_end scalars driving a do while (t < t_end) loop. The tolerance, implicit in ode45's defaults (RelTol 1e-3, AbsTol 1e-6), becomes an explicit tol argument to the adaptive stepper (see CS-02). There is no hidden state: everything MATLAB assumes, Fortran makes an argument — which is why the Fortran interface is longer and also why it is unambiguous.

C1 † — the second-order "RK4"

The bug is k3 = f(t + 0.5_dp*h, y + 0.5_dp*h*k1) — it reuses k1 where it must use k2. The stages must chain: $k_2$ from $k_1$, $k_3$ from $k_2$. With the bug, two stages are identical and the method collapses to second order. How to catch it without being told: integrate a known problem at $h$ and $h/2$ and check the error ratio — true RK4 gives $\approx 16$, this bug gives $\approx 4$. An order test is the only reliable check; the output otherwise looks plausible. The correct rk4_step is in exercise-solutions.f90.

C3 † — fixed-size RHS result with a 4-vector

Declaring the result real(dp) :: dydt(2) hard-codes size 2. Called through rk4_sys with a 4-component state, the stage assignment k1 = f(t, y) has k1 of size 4 (from size(y)) but f returns size 2 — a shape mismatch. With -fcheck=all it aborts at run time with a bounds/shape error; without it the behaviour is undefined (likely silent garbage or a crash). Fix: declare the result assumed-size from the input, real(dp) :: dydt(size(y)), so the RHS adapts to whatever state it is given — the same assumed-shape discipline that lets one rk4_sys serve every problem.

D1 † — modernize the F77 Euler subroutine

module integrators
  implicit none
  integer, parameter :: dp = selected_real_kind(15, 307)
  abstract interface
    function rhs(t, y) result(dydt)
      import :: dp
      real(dp), intent(in) :: t, y
      real(dp)             :: dydt
    end function rhs
  end interface
contains
  function euler(f, y0, h, n) result(y)
    procedure(rhs)       :: f
    real(dp), intent(in) :: y0, h
    integer,  intent(in) :: n
    real(dp)             :: y
    integer              :: i
    y = y0
    do i = 1, n
      y = y + h * f(0.0_dp, y)   ! t unused by an autonomous RHS; pass it anyway
    end do
  end function euler
end module integrators

Changes: implicit none and a dp kind replace implicit REAL; free-form replaces fixed columns; the DO 10 … 10 CONTINUE becomes a labelled-free do … end do; intent is added to every argument; and the hard-coded RHS (Y) becomes a procedure argument f, so the routine now integrates any equation, not just $y' = y$.

E1 † — RK4 on the heat MOL, one step

From a cold interior, one RK4 step of the 3-point heat system with $h = 0.1$ gives $(0.090775,\ 0.004392,\ 0.000142)$, versus the Euler step $(0.1, 0, 0)$. RK4 already spreads heat to points 2 and 3 in a single step because its intermediate stages ($k_2, k_3, k_4$) evaluate the spatial operator at interior points of the step, where point 1 has already warmed and can pass heat onward. Euler samples the operator only at the step's start, when points 2 and 3 still see zero gradient, so they stay exactly zero. Code: the E1 block of exercise-solutions.f90.

E3 — instability guard

After each step, test if (maxval(abs(u)) > 1.0e3_dp) then; print step number; error stop 1; end if. With $\Delta t = 0.6 > \Delta x^2/(2\alpha) = 0.5$ the explicit scheme amplifies the highest grid mode each step; $|u|$ grows geometrically and crosses $10^3$ within a few dozen steps, firing the guard. This is the CFL limit discovered experimentally — the ODE stability boundary of Euler applied to the MOL system (Chapter 24 derives it).

F1 † — Euler vs RK4 work estimate

Target global error $10^{-6}$ over $[0, 10]$ with error constant $\sim 1$. Euler (error $\sim h$): $h \sim 10^{-6}$, so $\sim 10/10^{-6} = 10^{7}$ steps, $\sim 10^{7}$ RHS evaluations. RK4 (error $\sim h^4$): $h \sim (10^{-6})^{1/4} \approx 0.0316$, so $\sim 316$ steps $\times 4 = \sim 1.3\times10^{3}$ evaluations. RK4 does roughly 8000× fewer RHS evaluations for the same accuracy — the whole case for higher order.

F3 † — RK4 memory on a million-point system

One RK4 step needs 4 RHS evaluations and holds four stage arrays $k_1$–$k_4$ plus the stage-argument temporaries, each $m = 10^{6}$ doubles $= 8$ MB, so $\gtrsim 40$–$60$ MB of array traffic per step. The arithmetic per element is a handful of flops, so arithmetic intensity is low and the step is firmly memory-bandwidth-bound — you are limited by how fast you can stream those arrays, not by the CPU's flop rate. (Chapter 28 measures exactly this; Chapter 29 attacks it.)

G1 † — why real(dp)

Round-off accumulates roughly as $N\varepsilon_{\text{mach}}$ over $N$ steps. Double precision: $\varepsilon \approx 2.2\times10^{-16}$, so $10^{6}\times\varepsilon \approx 2\times10^{-10}$ — negligible against any real tolerance. Single precision: $\varepsilon \approx 1.2\times10^{-7}$, so $10^{6}\times\varepsilon \approx 0.12$ — a 12% error from round-off alone, before any truncation error. The state must be real(dp); single precision is ruinous for a long integration (Chapter 20's error budget).

G3 † — backward Euler as a linear solve

For the 3-point heat MOL with $\alpha = \Delta x = 1$, $A = \begin{bmatrix} -2 & 1 & 0\ 1 & -2 & 1\ 0 & 1 & -2\end{bmatrix}$. Backward Euler solves $(I - \Delta t\,A)\,\mathbf{u}^{n+1} = \mathbf{u}^{n} + \mathbf{b}$, where $\mathbf{b}$ carries the boundary contributions $\Delta t\,(u_{\text{left}}, 0, u_{\text{right}})$. With $\Delta t = 0.1$, $u_{\text{left}} = 1$, $u_{\text{right}} = 0$: $$ (I - 0.1 A) = \begin{bmatrix} 1.2 & -0.1 & 0\\ -0.1 & 1.2 & -0.1\\ 0 & -0.1 & 1.2 \end{bmatrix}, \qquad \text{RHS} = \mathbf{u}^{n} + \begin{bmatrix} 0.1\\ 0\\ 0 \end{bmatrix}. $$ Solve this system each step with LAPACK's dgesv (dense) — or, since the matrix is tridiagonal and constant, factor it once with dgttrf and reuse, or call the tridiagonal solver dgtsv. This is the optional implicit path set up in Chapter 21's Project Checkpoint, and it is stable at any $\Delta t$.

G5 — whole-array MOL RHS (no loop)

c = alpha / dx**2
dudt(1)       = c * (u_left      - 2.0_dp*u(1)       + u(2))
dudt(2:n-1)   = c * (u(1:n-2)    - 2.0_dp*u(2:n-1)   + u(3:n))   ! one array section op
dudt(n)       = c * (u(n-1)      - 2.0_dp*u(n)       + u_right)

The interior update is a single whole-array expression over three shifted array sections — the same numbers as the loop, but clearer and easier for the compiler to vectorize (Chapter 5). Only the two boundary points need individual handling because they reference the fixed edge values.


Chapter 24 — Answers to Selected Exercises (PDEs and Finite Differences)

Full solutions to the odd-numbered and †-marked problems. Compilable ones are also in code/exercise-solutions.f90. All numeric outputs are hand-computed.

A1 † (predict one step; then destabilize)

After one step of the 1D rod (r = 0.25, ends 0 and 100, interior 0): only nodes 2,3,4 update. Nodes 2,3 see all-zero neighbourhoods → stay 0. Node 4 sees the hot end: 0 + 0.25*(100 - 0 + 0) = 25. So u = [0, 0, 0, 25, 100]. With r = 0.6 > 1/2, the highest-frequency mode's amplification factor is 1 - 4r = -1.4 (|·| > 1), so round-off-seeded oscillations flip sign and grow ~1.4× per step, overflowing to Inf/NaN within a few hundred steps.

A3 † (max interior T for r = 0.20 vs 0.30)

r = 0.20 ≤ 1/4: stable; interior warms smoothly, max T stays ≤ 100 (maximum principle) → ~1.000E+02. Trustworthy — put it in the paper. r = 0.30 > 1/4: unstable in 2D; max|u| grows geometrically, printing an astronomical value or Inf/NaN. Worthless. Lesson: compute and check r before believing output.

B5 † (1D Laplacian as one array section)

lap(2:n-1) = ( u(1:n-2) - 2.0_dp*u(2:n-1) + u(3:n) ) / dx**2

On u = (i-1)^2 = [0,1,4,9,16], dx=1: lap(2:4) = [0-2+4, 1-8+9, 4-18+16] = [2,2,2] — the exact d^2(x^2)/dx^2 = 2. (code/exercise-solutions.f90, solve_b5.)

B7 † (verify second-order accuracy)

u = sin(x)sin(y), exact ∇²u = -2 sin(x)sin(y). Compute max|lap_discrete - lap_exact| on grids h, h/2, h/4. The leading truncation term is O(h^2), so the error should fall ~4× per halving. Illustrative (Tier 3) magnitudes: 1.6e-2 → 4.1e-3 → 1.0e-3; ratios ≈ 3.9, 4.0. The ratio near 4 is the meaningful result — it certifies second-order accuracy independent of the constant.

C9 † (largest stable dt in 1D/2D/3D)

alpha = 1e-4, h = 0.01, so h^2 = 1e-4. 1D: h^2/(2α) = 1e-4/2e-4 = 0.5. 2D: /4α = 0.25. 3D: /6α = 0.1667. Each added dimension tightens the limit (pattern 1/(2d)), so 3D forces the smallest steps — more dimensions cost you time-resolution. (solve_c9.)

C10 † (grid-refinement blow-up — find the bug)

Hard-coded dt = 0.002, alpha = 0.1. At n=26, h = 1/25 = 0.04, r = 0.1*0.002/0.0016 = 0.125 (stable). At n=51, h = 1/50 = 0.02, r = 0.1*0.002/0.0004 = 0.5 (> 1/4 → blows up): halving h quadrupled r. Fix — derive dt from the grid every time it changes:

real(dp), parameter :: safety = 0.9_dp
dt = safety * h**2 / (4.0_dp*alpha)

C11 (running exactly at r = 1/4)

Checkerboard, r = 0.25, 4×4 fixed-boundary grid, cell (2,2): 1 + 0.25*(0 + (-1) + 0 + (-1) - 4) = 1 + 0.25*(-6) = -0.5 → magnitude 1 → 0.5 → 0.25 …, it decays on this finite Dirichlet grid. On an infinite grid the worst mode has |G| = |1 - 8(0.25)| = 1 — exactly marginal. Lesson: r = 1/4 is the edge; run a production job strictly below it, since round-off and any nonlinearity can nudge a marginal mode into growth.

C12 † (von Neumann analysis, 1D)

Substitute u^n_j = G^n e^{i k j h} into u^{n+1}_j = u^n_j + r(u^n_{j+1} - 2u^n_j + u^n_{j-1}): G = 1 + r(e^{ikh} - 2 + e^{-ikh}) = 1 + r(2cos(kh) - 2) = 1 - 4r sin^2(kh/2) (using 1 - cosθ = 2sin^2(θ/2)). |G| ≤ 1 for all k; the binding case is sin^2 = 1 (kh = π): 1 - 4r ≥ -1r ≤ 1/2. The 2D scheme adds a second sine term, both max out together, giving 1 - 8r ≥ -1r ≤ 1/4.

C13 (stability instrument)

r = alpha*dt/dx**2
if (r <= 0.25_dp) then
  print '(a, f8.4, a)', 'r = ', r, '  STABLE'
else
  print '(a, f8.4, a)', 'r = ', r, '  UNSTABLE (r > 1/4)'
end if

It belongs at program startup, right after reading the config — a check that turns a silent overnight failure into an immediate, self-explaining one. Printing r also documents the run in the log for reproducibility (Ch. 37). (merge would need both string branches the same length; an if is cleaner here.)

D14 † (both ends insulated)

Impose u(1)=u(2) and u(n)=u(n-1) each step (zero gradient), update interior with the stencil. With no flux across either end, total heat is conserved, so the bar relaxes to the uniform mean of the initial condition. Physical check: an isolated bar reaches one uniform temperature equal to its initial average.

D15 (periodic 1D step)

do i = 1, n
  im = 1 + modulo(i-2, n);  ip = 1 + modulo(i, n)     ! wrap both ends
  u_new(i) = u(i) + r*(u(ip) - 2.0_dp*u(i) + u(im))
end do

A single hot node spreads both ways and the two fronts meet on the far side (the domain is a ring). Total heat is conserved — there are no absorbing boundaries — so it relaxes to uniform, unlike Dirichlet ends which drain heat out.

D16 † (mixed BC: one Neumann edge, three Dirichlet)

Each step: (1) update the interior stencil; (2) re-impose boundaries — set the three Dirichlet edges to their fixed values and the insulated (left) edge to its inward neighbour u(1,:) = u(2,:). Boundaries must be re-imposed after the interior update; do it before and the Neumann edge (which mirrors the interior) lags one step behind and the flux is wrong.

D17 (port the NumPy Neumann edges)

u(1, :) = u(2, :)     ! top edge insulated  (NumPy axis 0 -> Fortran first index)
u(:, 1) = u(:, 2)     ! left edge insulated (NumPy axis 1 -> Fortran second index)

NumPy u[0] is the first row (0-based) → Fortran u(1,:) (1-based). Axis 0 ↔ first index, axis 1 ↔ second. The values are identical; only the base index and the memory order (NumPy C-major, Fortran column-major) differ — the latter matters for speed, not correctness, here.

E18 † (run_to_steady)

See case-study-02.md, Phase 1: loop step while maxval(abs(u - prev)) ≥ tol, capped at max_steps, returning the count. The max_steps cap is not optional — without it, a bug (or a too-tight tol that the scheme can never reach in floating point) loops forever; the cap guarantees termination. Step counts scale like grid-width × 1/r (information crawls one cell per step); Tier 3, problem-dependent.

E19 (constant heat source)

u^{n+1} = u^n + dt*(alpha*lap + s): add + dt*s to the interior update. It does not change the CFL limit — stability comes from the amplification of the homogeneous update (the stencil), and a constant source is an additive forcing that shifts the steady state but not the growth factor. The limit depends on the stencil's coefficients, not on any source term.

E21 (optional source argument)

subroutine step(field, alpha, dt, source)
  type(field_t), intent(inout) :: field
  real(dp), intent(in) :: alpha, dt
  real(dp), intent(in), optional :: source
  real(dp) :: s
  s = 0.0_dp;  if (present(source)) s = source
  ! ... field%u(interior) = field%u(interior) + dt*(alpha*lap(interior) + s)
end subroutine

Call call step(f, alpha, dt) (no source) or call step(f, alpha, dt, source=2.0_dp). The frozen positional signature is preserved; the new argument is optional and keyword-supplied (Ch. 6).

F22 † (modernize the F77 Jacobi kernel)

u_new(2:m-1, 2:n-1) = 0.25_dp * ( u(1:m-2, 2:n-1) + u(3:m, 2:n-1) &
                                + u(2:m-1, 1:n-2) + u(2:m-1, 3:n) )

with implicit none, free-form, real(dp), and intent(in) :: u / intent(out) :: u_new. The DO 2,N-1 bounds silently assume Dirichlet boundaries — the edge rows/columns are never written, so whatever fixed values they hold persist. The old code is exactly the five-point average.

F23 (relaxation = the r = 1/4 FTCS step)

Put r = 1/4 into u^{n+1} = u^n + r(sum - 4u^n) = u^n + (1/4)sum - u^n = (1/4) sum — the new value is just the average of the four neighbours, the Jacobi relaxation of F22. Relaxation converges to the steady state ∇²u = 0 (each point = neighbour average). r = 1/4 is the largest stable explicit step in 2D, so it is the fastest safe march toward steady state — which is why the old-timers hard-coded 0.25.

G24 † (steps for 10 s of physical time)

2D CFL dt: dt = h^2/(4α) = (0.005)^2/(4·1e-4) = 2.5e-5/4e-4 = 0.0625 s. Steps for 10 s: 10/0.0625 = 160 (with a 0.9 safety factor, ~178). Order of magnitude: a couple hundred steps.

G25 (memory footprint)

10000×10000 doubles = 1e8 × 8 = 8e8 bytes ≈ 0.8 GB per field. FTCS needs the field + one work array ≈ 1.6 GB minimum. Fits a workstation/cluster node, not a small laptop — and this is precisely why Ch. 34 splits the domain across processes, each holding only a slab.

G26 † (refinement cost)

Refine h → h/2, fixed physical time. 2D: points ×4, and dt ~ h^2 so steps ×4 → total 16×. 1D: 2 × 2 = . 3D: 8 × 4 = 32×. General rule: total work scales as h^{-(d+2)}. Explicit diffusion dreads fine grids because the dt ~ h^2 tax multiplies the step count on top of the extra cells.

H27 † (loop order)

Problem in one phrase: wrong loop nesting for column-major storage — the inner loop strides through memory. Fix:

do j = 2, n-1
  do i = 2, n-1          ! inner loop over the FIRST index
    u_new(i,j) = ...
  end do
end do

Fortran is column-major: u(i,j) and u(i+1,j) are adjacent, so i innermost walks contiguous memory and uses each cache line fully (Ch. 5; measured in Ch. 27).

H29 † (method of lines; Euler → RK4)

Discretizing space but leaving time continuous turns the PDE into one ODE per grid point, du_ij/dt = α∇²u = f(u); FTCS is forward-Euler on that system, u^{n+1} = u^n + dt·f(u^n). Swapping in RK4 changes only the time integrator — four stencil evaluations per step instead of one, raising time accuracy from O(dt) to O(dt^4). Unchanged: the stencil, the boundaries, the field_t. Changed: the stability limit (now RK4's, larger but still finite). The method-of-lines view makes the integrator swappable.

H30 † (defensive validation with error stop)

r = alpha*dt/h**2
if (r > 0.25_dp) then
  print '(a,f8.4)', 'FATAL: diffusion number r = ', r
  error stop 'unstable timestep (r > 1/4): reduce dt or coarsen the grid'
end if

Catching it at setup costs one comparison and converts a wasted overnight run (found as NaN at 3 a.m.) into an immediate, self-explaining failure. error stop (not stop) returns a nonzero exit code, so a batch scheduler registers the job as failed rather than "completed" (Ch. 13).


Chapter 25 — Scientific Data Formats

Solutions to the daggered (†) and odd-numbered problems. Problems 25.12, 25.25, and 25.29 are also worked as compilable code in code/exercise-solutions.f90. Design problems admit variations; model answers below.

25.1 The four failures of text at scale, and whether unformatted binary (Ch. 7 §7.5) fixes each: (1) Bulky — ~24 chars vs 8 bytes per double, ~3× inflation → fixed (binary writes the exact 8 bytes). (2) Slow — a decimal conversion per value each way → fixed (no conversion). (3) Lossy — formatted text can drop digits → fixed (exact bytes, bit-for-bit round trip). (4) Mute — no record of shape, type, units, or provenance → NOT fixed; raw binary is worse, adding an unrecorded byte order (endianness) and, for sequential unformatted, compiler-specific record markers. Self-describing formats fix all four.

25.3 dimension → NetCDF; dataset → HDF5; group → HDF5 (NetCDF-4 also has them, so "both" is acceptable); variable → NetCDF; attribute → both; coordinate variable → NetCDF; chunk → both (native in HDF5, inherited by NetCDF-4).

25.5 (a) NetCDF — publishing gridded data to climate scientists is exactly CF-NetCDF's home turf; the community's tools expect it. (b) HDF5 — a deep tree of thousands of nested datasets you analyze yourself needs HDF5's group hierarchy and control. (c) NetCDF — CF metadata (coordinate variables in metres) is what makes ParaView lay the axes out in physical space with no configuration.

25.7 (a) salinity holds $360 \times 180 \times 40 = 2{,}592{,}000$ values. (b) Declare it in Fortran as real :: salinity(lon, lat, depth) — the reverse of the CDL order — because ncdump lists axes row-major (slowest-varying first), while Fortran is column-major (fastest-varying first), so lon is the fastest index and comes first in the Fortran declaration. (c) lon(lon) is a coordinate variable (a 1D variable named like its dimension); it stores the longitude in degrees_east of each column, turning the index into a physical position. (d) _FillValue marks missing data — grid points with no valid value (e.g. salinity on land) — so tools skip them rather than plot the sentinel.

25.9 The failing call is nf90_put_var: it is issued while the file is still in define mode, before nf90_enddef. The rule: data may be written only in data mode. One-line fix — move nf90_enddef above nf90_put_var. (This is exactly the §25.4 "Find the Bug"; define all structure, end define mode, then write.)

25.11 A complete writer for grid.nc:

program write_mask
  use netcdf
  implicit none
  integer, parameter :: nx = 8, ny = 6
  integer :: mask(nx, ny), ncid, x_dimid, y_dimid, varid
  mask = 0;  mask(1, :) = 1                          ! a token land strip
  call check( nf90_create('grid.nc', NF90_CLOBBER, ncid) )
  call check( nf90_def_dim(ncid, 'x', nx, x_dimid) )
  call check( nf90_def_dim(ncid, 'y', ny, y_dimid) )
  call check( nf90_def_var(ncid, 'mask', NF90_INT, [x_dimid, y_dimid], varid) )
  call check( nf90_put_att(ncid, varid, 'long_name', 'land-sea mask') )
  call check( nf90_put_att(ncid, NF90_GLOBAL, 'title', 'coastline mask') )
  call check( nf90_enddef(ncid) )
  call check( nf90_put_var(ncid, varid, mask) )
  call check( nf90_close(ncid) )
contains
  subroutine check(s)
    integer, intent(in) :: s
    if (s /= nf90_noerr) then; print *, trim(nf90_strerror(s)); error stop 1; end if
  end subroutine
end program write_mask

Compile: gfortran -std=f2018 -Wall write_mask.f90 ``nf-config --fflags --flibs`` -o wm.

25.12 See solve_read_netcdf in code/exercise-solutions.f90. The reader opens heat.nc, nf90_inquire_dimensions to learn nx = 4, ny = 3, allocates, and nf90_get_vars. example-02 stored temperature(i,j) = 10*i + j, so the sum is $\sum_{i=1}^{4}\sum_{j=1}^{3}(10i+j) = 3(10+20+30+40) + 4(1+2+3) = 300 + 24 = \mathbf{324.00}$. The value is exact because the grid size is discovered from the file and the binary round trip is bit-for-bit.

25.13 Read an attribute with nf90_get_att(ncid, varid, 'units', units_string) into a character variable. Reading an attribute differs from reading a variable in three ways: it is retrieved by name (not by a variable ID over dimensions), it is metadata rather than array data (so nf90_get_att, not nf90_get_var), and for character attributes you read into a string long enough to hold it (blank the variable first, then trim). Attributes have no dimensions.

25.15 nf90_open(..., NF90_NOWRITE, ncid) opens the file read-only, so any nf90_put_var fails — you cannot write through a read-only handle. Fix: open with NF90_WRITE instead of NF90_NOWRITE. That mode permits modifying an existing file (writing variable data, adding attributes in some cases) while still opening it in place rather than clobbering it as nf90_create would.

25.17 Compression requires chunking because the deflate filter runs on one chunk at a time: a contiguously stored dataset has no chunks for the filter to operate on, so HDF5 has nothing to hand the compressor. For a $1000 \times 1000$ field you read back whole, a chunk shape of $(1000, 1)$ — one column per chunk, 1000 chunks — is a poor choice: it serves single-column reads well but forces a 2D region-of-interest to touch many chunks, and 1000 thin chunks carry more per-chunk bookkeeping than a handful of square tiles. Prefer either $(1000, 1000)$ (one chunk) if you only read wholes, or square tiles like $(100, 100)$ or $(250, 250)$ to balance whole and region reads.

25.19 Property-list portion for a $2000 \times 2000$ double, $256 \times 256$ chunks, shuffle + deflate 5:

integer(hid_t)   :: dcpl
integer(hsize_t) :: chunk_dims(2)
chunk_dims = [int(256, hsize_t), int(256, hsize_t)]
call h5pcreate_f(H5P_DATASET_CREATE_F, dcpl, hdferr)
call h5pset_chunk_f(dcpl, 2, chunk_dims, hdferr)
call h5pset_shuffle_f(dcpl, hdferr)        ! BEFORE deflate
call h5pset_deflate_f(dcpl, 5, hdferr)
! ... pass dcpl to h5dcreate_f ...

Shuffle reorders the bytes of the dataset so that all the first bytes of every value are grouped, then all the second bytes, and so on. In a smooth field, neighbouring real(dp) values share almost identical high-order bytes, so grouping them creates long runs of near-identical bytes that deflate compresses far better. It must run before deflate because it is a reversible pre-transform whose only job is to rearrange the data into a more compressible order for the compressor that follows.

25.21 CF improvements to run.nc: give temp a units (e.g. "K"), a long_name, and a _FillValue; add coordinate variables x(x) and y(y) with units = "m" and axis; add global title, source, history, and Conventions. Three of the nf90_put_att calls:

call check( nf90_put_att(ncid, temp_varid, 'units', 'K') )
call check( nf90_put_att(ncid, temp_varid, 'long_name', 'temperature') )
call check( nf90_put_att(ncid, NF90_GLOBAL, 'Conventions', 'CF-1.11') )

25.23 Adding standard_name = "plate_temperature" can be worse than nothing because standard_name is a controlled vocabulary — only strings in the published CF standard-name table are valid. An invented value is non-compliant, may be rejected or misinterpreted by CF-aware tools, and falsely advertises compliance the file does not have. For a quantity with no matching table entry, omit standard_name and use the free-text long_name instead (which has no vocabulary restriction). A correct long_name beats a fabricated standard_name.

25.25 See solve_timeseries in code/exercise-solutions.f90. Define a time dimension as NF90_UNLIMITED, one variable temperature(x, y, time), and append record rec with start = [1, 1, rec], count = [nx, ny, 1]. The unlimited dimension must be the slowest-varying — the last index in the Fortran dimension list (which ncdump prints first, as temperature(time, y, x)) — because NetCDF grows the file by appending whole records along the outermost dimension; in Fortran's column-major layout the outermost/slowest index is the last one.

25.27 Make the field chunked and compressed by adding optional arguments to nf90_def_var:

call check( nf90_def_var(ncid, 'temperature', NF90_DOUBLE, [x_dimid, y_dimid], &
                         t_varid, chunksizes=[100, 100], shuffle=.true., deflate_level=5) )

Requires the file be NF90_NETCDF4 (HDF5 underneath) — the classic format has no chunking. For a $1000 \times 1000$ field, $100 \times 100$ chunks (a $10 \times 10$ grid of 80 kB tiles) balance whole-field and region reads. Flag: verify against your installed netcdf-fortran the exact optional keyword names (chunksizes, shuffle, deflate_level) and whether shuffle/deflate are logical or integer in the keyword form — some versions expose the standalone nf90_def_var_deflate/ nf90_def_var_chunking routines instead. Do not ship an archive writer without a ncdump -hs check.

25.29 See solve_storage. Bytes per snapshot $= 1000 \times 1000 \times 8 = 8{,}000{,}000$; over 1000 snapshots $= 8 \times 10^{9}$ bytes $= \mathbf{8.0}$ GB raw (decimal); at 4× compression $= \mathbf{2.0}$ GB. The byte total must use integer(int64): default 32-bit integer maxes out near $2.1 \times 10^{9}$, so $8 \times 10^{9}$ overflows it (wrapping to a negative or wrong value). integer(int64) holds up to $\sim 9.2 \times 10^{18}$, comfortably safe. Compute the product in int64 from the start — int(nx, int64) * int(ny, int64) * 8_int64 * n_snaps — or the multiplication overflows before the assignment widens it.

25.31 Using the given illustrative rates (0.8 s per 24 MB text snapshot, 0.02 s per 8 MB binary snapshot) over 25.30's 500-snapshot schedule: text output costs $500 \times 0.8 = 400$ s $\approx 6.7$ minutes of pure I/O; binary costs $500 \times 0.02 = 10$ s. Text output is ~40× the wall-clock, and it recurs on every reload. Lesson: at scale, text I/O is a first-order cost, not a rounding error — self-describing binary removes it while adding portability and metadata. The rates are Tier-2 illustrative (they depend on the machine, the format string, and the I/O subsystem), not measured — say so, and confirm on your own hardware before quoting a speedup.

25.32 The Fortran HDF5 writer must make explicit five things Python's h5py hides: (1) initialize the library (h5open_fh5py does it on import); (2) build a dataspace (h5screate_simple_f with dims = [100,100]h5py infers shape from the NumPy array); (3) build a dataset-creation property list for chunk + compression (h5pcreate_f, h5pset_chunk_f(dcpl,2,[10,10]), h5pset_deflate_f(dcpl,5) — the chunks=/compression='gzip'/compression_opts=5 keywords); (4) create the dataset with that property list (h5dcreate_f(..., dcpl)) and write it (h5dwrite_f); (5) close every handle and finalize (h5pclose_f, h5dclose_f, h5sclose_f, h5fclose_f, h5close_fh5py's with block closes for you). Sketch:

call h5open_f(hdferr)
call h5fcreate_f('field.h5', H5F_ACC_TRUNC_F, file_id, hdferr)
call h5screate_simple_f(2, [100_hsize_t, 100_hsize_t], space_id, hdferr)
call h5pcreate_f(H5P_DATASET_CREATE_F, dcpl, hdferr)
call h5pset_chunk_f(dcpl, 2, [10_hsize_t, 10_hsize_t], hdferr)
call h5pset_deflate_f(dcpl, 5, hdferr)
call h5dcreate_f(file_id, 'temperature', H5T_NATIVE_DOUBLE, space_id, dset_id, hdferr, dcpl)
call h5dwrite_f(dset_id, H5T_NATIVE_DOUBLE, u, [100_hsize_t, 100_hsize_t], hdferr)
call h5pclose_f(dcpl,hdferr); call h5dclose_f(dset_id,hdferr)
call h5sclose_f(space_id,hdferr); call h5fclose_f(file_id,hdferr); call h5close_f(hdferr)

The lesson is not that Fortran is more verbose for its own sake — it is that Python's convenience is these same steps, hidden. (10_hsize_t requires hsize_t to be in scope from use hdf5; declaring an integer(hsize_t) array and assigning [10,10] is equally fine.)

25.33 The bug is in the h5dcreate_f call: it omits the dcpl argument, so the dataset is created with the default property list — contiguous storage, no filter — and the h5pset_chunk_f/ h5pset_deflate_f settings on dcpl are silently ignored (the property list is built but never attached to anything). The dataset writes correctly, just uncompressed. Fix: pass dcpl as the trailing optional argument — call h5dcreate_f(file_id, 'temperature', H5T_NATIVE_DOUBLE, space_id, dset_id, hdferr, dcpl). The property list only takes effect when it is handed to h5dcreate_f.

25.34 Because the solver already holds the field in memory when it calls the raw writer, the cleanest wrap is to add a NetCDF writer at the same call sitecall write_field_netcdf(field, name, step, time) beside (or instead of) the write(u) field — without touching the physics. (A pure post-processing shim that re-reads the .bin and re-emits NetCDF is possible but needs a sidecar recording the shape/type, which is exactly the metadata the raw file lacks — proof of the point.) The three things the raw unformatted file discards that the NetCDF version must record: (1) the array's shape and which index is x vs y (the raw bytes record no dimensions); (2) the numeric type and byte order (real64? little- or big-endian? — raw records neither, so the file is non-portable); (3) the metadata — units, grid spacing/coordinates, and provenance (source, history). Preserve the science (the bytes), add the description.

25.35 The four-step data flow, with the mechanism at each: (1) config file → parametersnamelist read (Ch. 7), read(u, nml=config); (2) parameters → field object — the field_t constructor (Ch. 9), call f%init(nx, ny, dx, dy); (3) field object → evolved state — the solver's time loop mutates f%u; (4) field object → output filewrite_field_netcdf (Ch. 25) → self-describing NetCDF. Of these, the NetCDF output is self-describing (shape, type, units, provenance travel with it). The namelist is human-readable and its names are self-documenting, but you must still know what each name means; a raw text field dump (the pre-Ch. 25 output) relies entirely on remembering what the numbers are. Only the NetCDF file explains itself to a stranger.


Chapter 26 — Visualization Output

Solutions to the daggered (†) and odd-numbered problems. The programs among them are in code/exercise-solutions.f90; port and design problems have more than one good answer, so a model is given. Every VTK/ASCII output below was hand-constructed against the format skeleton, not run.

1. Before opening heat_000100.vtk: DIMENSIONS 3 2 1, POINT_DATA 6, and the six value lines 0.000000 / 1.000000 / 2.000000 / 3.000000 / 4.000000 / 5.000000 (VTK order: u(1,1),u(2,1),u(3,1) then u(1,2),u(2,2),u(3,2)). The magic string is line 1, # vtk DataFile Version 3.0 — the exact, case-sensitive token that identifies the file as VTK; without it, ParaView will not open the file at all.

3. A legacy VTK POINT_DATA section may carry several SCALARS arrays, one after another. After the temperature block and its values, append a second block:

write(iu, '(a)') 'SCALARS error double 1'
write(iu, '(a)') 'LOOKUP_TABLE default'
do j = 1, field%ny
  do i = 1, field%nx
    write(iu, '(f0.6)') field%u(i, j) - u_exact(i, j)
  end do
end do

There is only one POINT_DATA n line (the count is shared — every array has one value per point); each array gets its own SCALARS name .../LOOKUP_TABLE default header and its own n values. After opening, ParaView's coloring dropdown offers both temperature and error, so you can color by either without rewriting the file.

5. Call write_vtk(plate,'heat.vtk',0) and write_vti(plate,'heat.vti',0) on the same field; both open in ParaView and render an identical 3×2 plate. Three structural differences: (a) the legacy file is positional plain text (a reader parses fixed lines in order), while .vti is tagged XML (attributes name each piece); (b) the legacy DIMENSIONS gives point counts (3 2 1) while the .vti WholeExtent gives 0-based inclusive point indices (0 2 0 1 0 0); (c) the XML format can carry a compressed or binary DataArray and split into per-rank parallel pieces (.pvti), which the legacy ASCII format cannot.

7. (see code/exercise-solutions.f90, write_csv) MATLAB's flipud(U) puts the top row first, so the Fortran loop runs j from ny down to 1; the : (colon) edit descriptor stops at the end of the I/O list, giving comma separation with no trailing comma:

do j = field%ny, 1, -1
  write(iu, '(*(f0.3, :, ","))') (field%u(i, j), i = 1, field%nx)
end do

For the 3×2 field this writes 3.000,4.000,5.000 then 0.000,1.000,2.000.

9. The bug is in the DIMENSIONS line: the third value is 2, so the header declares a 3 × 2 × 2 = 12 point grid, but POINT_DATA is nx*ny = 6 and only 6 values are written — hence "Expected 12 points but could only read 6." A 2-D field has nz = 1. Fix: write 1 (not 2) as the third dimension: write(iu, '(a, 3(1x, i0))') 'DIMENSIONS', field%nx, field%ny, 1. Now DIMENSIONS 3 2 1 implies 6 points, matching POINT_DATA 6 and the six values.

11. The magic string is missing the space after #. VTK requires the first line to be exactly # vtk DataFile Version 3.0 — hash, space, lowercase vtk, space, DataFile Version, space, version. The writer emitted #vtk … (no space), which does not match the required token, so ParaView refuses the file. Fix: write(iu, '(a)') '# vtk DataFile Version 3.0'. VTK is unforgiving here because the reader uses the first line as a format signature: a single-character difference means "this is not a VTK file."

13. The <DataArray> has no Name attribute (and <PointData> no Scalars), so ParaView reads the values but has no named array to expose in the coloring dropdown — the dataset opens but appears empty (nothing to color by). Fix: name the array (and, optionally, mark it the active scalar):

write(iu,'(a)') '      <PointData Scalars="temperature">'
write(iu,'(a)') '        <DataArray type="Float64" Name="temperature" format="ascii">'

The Name="temperature" is what populates the dropdown; Scalars="temperature" makes it the default active array.

15. Strategy: the concatenated single file records every timestep but is not addressable — a viewer cannot jump to frame 200 without scanning the whole file, and ParaView cannot animate it. Replace the single append with a per-step VTK file written from inside the time loop, named with a zero-padded index:

do step = 0, n_steps
  if (mod(step, save_every) == 0) call write_vtk(field, frame_name(step), step)
  call step_field(field, alpha, dt)
end do

This unlocks what the single file cannot offer: ParaView detects the numbered set as one time-varying source and gives you a time slider and a play button — you can scrub directly to any saved frame and animate the run. The trade is many small files instead of one big one, which is exactly what the padded naming and (for tidiness) an output/ subfolder manage.

17. (see code/exercise-solutions.f90, write_output) One entry point dispatches on the format string with select case (the construct from Chapter 4), building the filename per format and calling the matching writer, with error stop on an unknown format:

select case (fmt)
case ('vtk');    write(buf,'(a,i6.6,a)') 'heat_', step, '.vtk'; call write_vtk(field, trim(buf), step)
case ('csv');    write(buf,'(a,i6.6,a)') 'heat_', step, '.csv'; call write_csv(field, trim(buf))
case ('matrix'); write(buf,'(a,i6.6,a)') 'heat_', step, '.dat'; call write_matrix(field, trim(buf))
case default;    error stop 'write_output: unknown format (use vtk, csv, or matrix)'
end select

select case on a character key is cleaner than a chain of if … else if and makes the closed set of supported formats obvious.

19. (see code/exercise-solutions.f90, write_vtk_3d) For a field u(:,:,:) on nx × ny × nz: DIMENSIONS becomes nx ny nz (real values, not 1 for the third); POINT_DATA is nx*ny*nz; SPACING carries the real dz; and the loop nest gains a k loop outside j:

do k = 1, field%nz
  do j = 1, field%ny
    do i = 1, field%nx
      write(iu, '(f0.6)') field%u(i, j, k)
    end do
  end do
end do

VTK's 3-D point ordering is x (i) fastest, then y (j), then z (k) — so i is innermost, k outermost, which is again exactly Fortran's column-major order for u(i,j,k).

21. For 256 × 256 = 65,536 points: ASCII legacy VTK at ~9 bytes/value is about 590 KB per frame; double-precision binary is 65,536 × 8 = 524,288 bytes ≈ 512 KB per frame. So at 6 decimals they are roughly comparable in size here — but ASCII pays two costs regardless: (1) precision — six decimals record only part of a double's ~15–16 significant digits, so the file is not a bit-exact record (round-trip loses information); and (2) conversion time — every value must be formatted to decimal on write and parsed back on read, far slower than copying raw bytes. At a billion cells the conversion time dominates (and if you keep full precision, es24.16 makes ASCII ~3× larger too) — which is why large runs use the binary formats of Chapter 25.

23. One 1000 × 1000 ASCII frame is 10^6 values × ~9 bytes ≈ 9 MB. At 500 MB/s, the pure disk time is about 9 MB / 500 MB/s ≈ 18 ms. But formatting 10^6 doubles to decimal — each conversion is on the order of 10^210^3 ns — costs roughly 0.11 second, one to two orders of magnitude more than the disk time. So the write is limited by the per-value formatting (CPU), not disk bandwidth: text conversion is the bottleneck. (Writing many values per record instead of one write per value trims record overhead but not the formatting cost — the real fix is binary, Chapter 25.)

25. write(iu, '(a, 3(1x, i0))') 'DIMENSIONS', 100, 80, 1 places, group by group: aDIMENSIONS; then the group (1x, i0) repeats three times — 1x→one space, i0100; space, 80; space, 1 — giving the record DIMENSIONS 100 80 1. Use i0, not i4, because i0 self-sizes to the value's minimum width (no leading blanks) and never overflows: a dimension of 10000 would fill a 4-wide i4 field with asterisks (****), while i0 simply prints 10000. A grid dimension can be any magnitude, so the self-sizing descriptor is the safe one.

27. (a) Fortran stores u(i,j) with the first index fastest (column-major), so the elements u(1,j), u(2,j), u(3,j), … are adjacent in memory. The loop do j; do i; … u(i,j) visits them in that adjacent order, so each access is a short hop from the last — maximal spatial locality, cache-friendly. (b) Yes: the Chapter 24 stencil sweep u(i,j) = f(u(i±1,j), u(i,j±1)) should also put i innermost, so consecutive updates touch consecutive memory (and the u(i±1,j) neighbors are one element away). Looping j inner would stride by a whole column (ny elements) per step, thrashing the cache — the exact effect Chapter 27 measures as a 10× penalty. (c) General rule: make the innermost loop run over the first (fastest-varying) array index, matching the loop nest to the column-major memory layout. When a file format (VTK) also wants that index fastest, one and the same loop nest is simultaneously correct for the format and optimal for the cache.


Chapter 27 — Why Fortran Is Fast

Full solutions to the daggered (†) and odd-numbered problems. The compilable ones (27.13, 27.19, and the kernels behind 27.18/27.24) are in code/exercise-solutions.f90. Every output was hand-computed; the "answers" to timing questions are reasoning, since no code was run. Conceptual problems admit more than one good wording — a model is given.

27.1 -O0: no optimization — compiles fast, maps line-for-line to source, best for debugging. -O2: the safe production workhorse — inlining of small procedures, common-subexpression elimination, strength reduction, and much more, while preserving IEEE floating-point. -O3: everything in -O2 plus auto-vectorization and more aggressive inlining. -Ofast: -O3 plus -ffast-math, which lets the compiler reorder floating-point arithmetic and assume no NaN/Inf. The key change -O2-O3 is auto-vectorization turns on; the key change -O3-Ofast is that -ffast-math can change your numerical results, so it is opt-in and must be validated.

27.3 From the compiler's point of view: because the standard guarantees that a procedure's written arguments do not overlap its read arguments, the compiler may assume distinctness for free and reorder, load, store, and vectorize without a runtime aliasing check.

27.5 -fopt-info-vec lists the loops the compiler successfully vectorized; -fopt-info-vec-missed lists the loops it failed to vectorize, with the reason. Pair either with -O3 (there is nothing to report at lower levels, where vectorization is off).

27.7 (a) One column of a(512,512) is 512 × 8 bytes = 4096 bytes = 4 KB, which is 4096 / 64 = 64 cache lines. (b) Consecutive elements within a rowa(i,1), a(i,2), a(i,3), … — are one full column apart in memory (4096 bytes), so each lands on a different cache line. Reading one full row of 512 elements therefore touches about 512 different cache lines, fetching 512 × 64 = 32,768 bytes to use only 512 × 8 = 4096 bytes — seven-eighths of the bandwidth wasted. That is why looping across a row (inner loop over the last index) is the slow pattern.

27.9 A loop is memory-bound when its speed is limited by how fast data can be moved between memory and the CPU, not by how many arithmetic operations it performs. For such a loop the floating-point units sit idle much of the time waiting for the next array element to arrive; cutting the arithmetic (fewer adds/multiplies) does not help, because arithmetic was never the bottleneck — the loop still waits the same amount for memory. What does help is moving less memory or moving it more efficiently: correct (with-the-grain) loop order so each cache line is fully used, cache blocking to reuse data before it is evicted, and fusing passes so an array is read once instead of several times (the material of Ch. 29).

27.11 In Fortran, c = a + b on distinct assumed-shape arrays carries the standard's guarantee that c does not alias a or b; the compiler therefore knows writing c(i) cannot change a value it is about to read, so it vectorizes directly, with no runtime check. In C the same loop over pointers c[i] = a[i] + b[i] carries no such guarantee — the pointers might overlap — so the compiler must either serialize (conservative) or emit a runtime overlap check and two loop versions (a vectorized path and a scalar fallback), paying a branch and code bloat. C99's restrict lets the programmer promise, per pointer, that it does not alias, recovering the optimization. It is weaker than Fortran's guarantee because it is opt-in, per-pointer, and unenforced — the programmer must remember it and must not get it wrong, whereas Fortran's non-aliasing is the default state of the world for every argument.

27.13 Add elemental (which makes it automatically pure); the scalar body then applies to a whole array with no loop. See code/exercise-solutions.f90 (to_kelvin):

elemental function to_kelvin(celsius) result(k)
  real(dp), intent(in) :: celsius
  real(dp)             :: k
  k = celsius + 273.15_dp
end function to_kelvin
! whole-array call:
k_array = to_kelvin(t)          ! t(:) in Celsius -> k_array(:) in Kelvin

For t = [0, 100, -40] the result is [273.15, 373.15, 233.15].

27.15 The defect is the call call shift_avg(v, v): the same array v is passed as both the intent(out) argument b (written) and the intent(in) argument a (read). That aliases a written argument with a read one, which the Fortran standard forbids — it is undefined behavior. Why the result can change at -O3: at -O0 the compiler runs the loop literally, so b(i) = 0.5*(a(i-1)+a(i+1)) reads whatever a currently holds — and since b is a, a(i-1) was overwritten on the previous iteration, giving a deterministic cascading result. At -O3, trusting the no-alias guarantee, the compiler may vectorize — loading a block of the original a values before any writes land — producing a different answer. Neither is "the bug"; the bug is that the program was never valid, and the two optimization levels disagreeing is the symptom. Fix: use distinct arrays (call shift_avg(v, w)); if in-place is genuinely wanted, write it explicitly with a single intent(inout) argument and a temporary.

27.17 Two plausible causes of missed: not vectorized: possible dependence between data-refs: (1) A genuine loop-carried dependence — the loop body reads a value written in a previous iteration (e.g. a recurrence x(i) = x(i-1) + … or a running sum). Check: does element i depend on element i-1? If so it is inherent; consider a different algorithm (prefix-sum, or a reduction the compiler recognizes). (2) An aliasing worry the compiler could not rule out — often an impure procedure call in the loop body, or arrays reached through pointers/targets where distinctness is not guaranteed. Check: mark helpers pure, use allocatable (not pointer) arrays, and ensure written and read arrays are distinct; add contiguous where a pointer dummy is genuinely contiguous (Ch. 11/29). A third, mundane cause: optimization was simply off — confirm -O3 is present.

27.19 See code/exercise-solutions.f90 (smooth_sections and smooth_loop). The whole-array (section) form:

unew(2:nx-1,2:ny-1) = 0.25_dp * ( u(1:nx-2,2:ny-1) + u(3:nx,2:ny-1)   &
                                + u(2:nx-1,1:ny-2) + u(2:nx-1,3:ny) )

and the explicit loop in cache-friendly order (inner over i) gives the identical field (both read only old u). On a 4×4 hot-top plate the interior is [[25,25],[0,0]]. Speedup class over pure Python: a pure-Python double loop over elements pays the interpreter's overhead every iteration; the Fortran (compiled, vectorized, cache-friendly) version is typically 50–100× faster for a loop like this — an illustrative order of magnitude you would confirm with the f2py measurement of Ch. 15. (NumPy's own section form is fast because it too runs a compiled loop; the cliff is the pure-Python loop.)

27.21 No — turning on vectorization will not give a 4× speedup for a memory-bound loop. Vectorization speeds up the arithmetic: four-wide instructions finish the adds and multiplies four times faster. But if the loop is memory-bound, the arithmetic was never the bottleneck — the loop is limited by how fast the arrays stream from memory (memory bandwidth), and doing the arithmetic faster just means arriving at the "wait for the next cache line" point sooner. The full 4× appears only when the loop is compute-bound — when arithmetic is the limit and the data it works on is already in registers/cache (high arithmetic intensity, as in dense matmul). For the triad/stencil (low intensity), vectorize by all means, but the real wins come from moving less memory (loop order, blocking).

27.23 Amdahl-style: only the 80% hot fraction speeds up 10×; the other 20% is unchanged. New time = $0.2 \times 5 + 0.8 \times 5 / 10 = 1.0 + 0.4 = \mathbf{1.4}$ hours (overall speedup $5/1.4 \approx 3.6\times$ — note the un-sped-up 20% now dominates, the whole point of Amdahl's Law, Ch. 31). Each run now saves $5 - 1.4 = 3.6$ hours = 216 minutes; the fix cost 30 minutes, so it pays for itself during the very first run (0.5 h invested, 3.6 h saved). After one run you are ahead.

27.25 A minimal -O0-vs--O3 experiment. (a) Compute a large vectorizable reduction or triad — e.g. s = sum(a * b) over an allocatable array of ~$10^6$ real(dp), wrapped in a timed loop of many repetitions. (b) Print the checksum (s, or c(1)+c(n)) and the elapsed system_clock time. (c) You must print/consume the checksum so the compiler cannot delete the timed work as dead code — an unused result may be optimized away entirely, and you would "measure" an empty loop. (d) Compile the same source twice — gfortran -std=f2018 -O0 x.f90 -o x0 and gfortran -std=f2018 -O3 -march=native x.f90 -o x3 — run both, and compare the elapsed times (the checksum must match to prove you timed the same computation). Report the ratio as your measurement, with grid size, compiler, and flags recorded (the reproducibility habit of Ch. 37).

27.27 Evaluating the section-based Laplacian traverses u in column-major order — the compiler walks each section's first index fastest (down columns), which is cache-friendly, exactly as a first-index-inner hand loop would be. Writing it as sections (rather than a hand loop) affects aliasing assumptions favorably: the result array lap is a distinct array being written, while all the shifted u(...) terms are reads of u. The shifted read-sections do overlap each other in memory (u(1:n-2,·) and u(3:n,·) share elements), but overlapping reads are harmless — only a write overlapping a read is the hazard, and there is none here (lapu). So the whole-array form hands the compiler both the cache-friendly traversal and a clean, guaranteed-distinct write target — more information to optimize with, not less, than the equivalent explicit loop. (If you wrote u in place instead of into a separate lap, that would alias a written array with its own read-sections — the bug of §27.3.)


Chapter 28 — Answers to Selected Exercises (Profiling and Benchmarking)

Full solutions to the odd-numbered and †-marked problems. Compilable ones are also in code/exercise-solutions.f90. All numeric outputs are hand-computed; all timing figures are representative and labelled as such.

A1 † (the sum is exact; the time is not)

array sum = 10000000.00, exactly. Summing ten million 1.0_dp values: every partial sum is an integer below $2^{53}$, and integers below $2^{53}$ (and their sums, while still below it) are represented exactly in IEEE double, so no rounding occurs regardless of summation order. The time varies run to run because it depends on cache state, CPU frequency, and what else the machine is doing — physical conditions, not the arithmetic. The sum is a property of the program; the time is a property of the machine on that run.

A3 † (elapsed seconds, with wrap-around)

Case (i): $(3{,}700{,}000 - 3{,}200{,}000)/1{,}000{,}000 = 500{,}000/1{,}000{,}000 = 0.5$ s. Case (ii) the clock wrapped: raw end - start = 100 - 999{,}900 = -999{,}800 < 0, so add count_max + 1 = 1{,}000{,}000: -999{,}800 + 1{,}000{,}000 = 200 ticks; $200/100{,}000 = 0.002$ s. Confirmed by solve_a3: 0.5000 s, 0.0020 s.

A4 † (predict the checkpoint block)

Identical to Chapter 24's hand-trace ($r = 0.2$, hot top edge). After two steps the second row is 0, 28, 32, 28, 0 and the third 0, 4, 4, 4, 0, all else 0 (top row held at 100). The maxval u = 100.00 of the timed 128×128 run is also exactly predictable: the top edge is Dirichlet-held at 100 and never updated, and with $r = 0.225 \le 1/4$ the discrete maximum principle keeps the interior in $[0,100]$, so the maximum is always the held edge value, 100 — independent of the (machine-dependent) time.

B5 † (three bugs in the timer)

  1. Default-integer counters. integer :: c0, c1 selects a coarse clock with a small count_max that wraps quickly; a long run can end with c1 < c0, giving a negative elapsed time — the "weird numbers." Use integer(int64).
  2. Guessed rate. / 1000.0_dp hard-codes count_rate instead of reading it; unless the true rate is exactly 1000, the seconds are wrong by that factor.
  3. Never read the actual rate. No count_rate= request appears at all. Correct version:
integer(int64) :: c0, c1, rate
call system_clock(count_rate=rate)
call system_clock(count=c0);  ! ... work ... ;  call system_clock(count=c1)
secs = real(c1 - c0, dp) / real(rate, dp)

B7 (wall vs CPU per scenario)

(a) Serial matmul, idle laptop: wall $\approx$ CPU (CPU busy the whole time). (b) Read + sum a 10 GB file: wall $\gg$ CPU (most time waiting on disk — I/O-bound). (c) 8-thread OpenMP reduction: CPU $>$ wall (up to $\sim 8\times$, CPU-seconds summed across cores). (d) Shared login node: wall $\gg$ CPU (descheduled while other users' jobs run — contention).

C9 † (the add-arithmetic experiment)

Original body: s = s + x(i). Instrumented body: s = s + x(i)*1.0000001_dp (or accumulate a second value kept in a register) — an extra flop on data already loaded, adding no memory traffic. Re-time both. If the instrumented loop is essentially as fast, the FP units had spare capacity → memory-bound. If it slows in proportion to the added flops → compute-bound. The no-new-memory constraint is essential: introduce a new array load and you change the memory traffic, confounding the test.

C10 † (arithmetic intensity)

axpy y = y + a*x: 2 flops (one multiply, one add) per element; bytes = two 8-byte loads + one 8-byte store = 24 → $I = 2/24 \approx 0.083$ flop/B. Five-point stencil: ~6 flops; fresh traffic ~one 8-byte load + one 8-byte store = 16 → $I = 6/16 = 0.375$ flop/B. Both $< 1$ → memory-bound. axpy points to "nothing to do, bandwidth-limited"; the stencil points to Chapter 29 (loop order, blocking). Confirmed by solve_c10.

C11 (naïve matmul vs dgemm)

A naïve triple loop computes each $C_{ij}$ streaming a full row and column, reusing each loaded element $O(1)$ times before eviction → low effective intensity, memory-bound. dgemm blocks (tiles) the computation so a sub-tile of $A$, $B$, $C$ stays resident in cache and is reused $O(\text{tile})$ times, raising arithmetic intensity until the FP units, not memory, are the limit → compute-bound. The single technique doing most of the work is cache blocking (Ch. 29); it is why you cannot beat BLAS by trying harder at the arithmetic (Ch. 21).

D13 † (reading the call graph)

step_ is called 2000 times, and laplacian_ is called 2000/2000 times from step_ — so step_ is laplacian_'s only caller. step_'s time splits as self = 0.85 s and children = 3.90 s (all of it in laplacian_). Optimizing step_'s own body could reclaim at most its 0.85 s of self time (~17%), while the 3.90 s (78%) lives in the stencil inside laplacian. The call graph tells you to optimize laplacian, not step.

D15 (port cProfile → gprof)

Python: python -m cProfile -o out.prof sim.py; then inspect the cumtime/tottime columns. Fortran equivalent, three commands: (1) gfortran -O2 -pg -g sim.f90 -o sim — compile+link with instrumentation (the extra flag Python does not need, since CPython is already interpreted/instrumentable); (2) ./sim — run, producing gmon.out; (3) gprof ./sim gmon.out — read the flat profile (self/% timetottime) and call graph (≈ cumtime). The conceptual mapping is one-to-one; only the compile-with--pg step is new.

E16 † (mean vs median on a noisy set)

t = [0.10, 0.10, 0.10, 0.30]. Mean $= 0.60/4 = 0.15$. Sorted [0.10,0.10,0.10,0.30], median (even count) $= (0.10+0.10)/2 = 0.10$. The median (0.10) better represents typical speed; the single 0.30 outlier (a scheduling hiccup) drags the mean 50% high. The median is robust to outliers; the mean is not. Confirmed by solve_e16: 0.1500, 0.1000.

E17 † (design a benchmark harness)

subroutine bench(kernel, n_rep, best, med)
  procedure(ikernel) :: kernel                 ! abstract interface: subroutine kernel()
  integer, intent(in) :: n_rep
  real(dp), intent(out) :: best, med
  real(dp) :: t(n_rep), sorted(n_rep)
  integer  :: r
  call kernel()                                ! warm-up (untimed)
  do r = 1, n_rep
    call tic();  call kernel();  t(r) = toc()
  end do
  best = minval(t)
  sorted = t;  call sort_ascending(sorted);  med = median_of(sorted)
end subroutine

Defeat dead-code elimination by having kernel write to a module variable that is later printed (so its work is observable). Return two statistics because they answer different questions: the minimum is the best-case cost of the code itself (noise only slows), while the median is the typical cost under real conditions — reporting both, with the spread, is honest.

E19 (min vs median by goal)

(a) "Fastest possible on this CPU" → minimum: disturbances only slow a run, so the fastest is the least-contaminated, closest to the code's true cost. (b) "Latency a user typically sees on a busy machine" → median: it captures typical behaviour including routine contention, and is robust to the occasional extreme outlier that would distort a mean.

F21 † (loop order and miss rate)

The order with the inner loop over the first array index has the lower D1 miss rate. Fortran is column-major, so consecutive first-index elements are adjacent in memory; walking them contiguously uses each cache line fully before eviction (few misses). The other order strides by a whole column per step, touching one element per line and evicting lines before reuse (many misses). Their D refs are identical because both do the same arithmetic on the same array — the amount of work is unchanged; only locality differs. Chapter 27 proved the effect qualitatively; §28.4 (cachegrind) measures it.

F22 (cachegrind vs perf divergence)

Two reasons the simulated miss rate and the real time cost diverge: (1) cachegrind models a generic cache (idealised sizes/associativity), not your exact CPU, so its counts are directional, not absolute; and (2) real CPUs hide miss latency with out-of-order execution, hardware prefetching, and memory-level parallelism, so many "misses" overlap and cost far less wall time than a naïve per-miss penalty predicts. Trust cachegrind for why and where (reproducible locality analysis); trust perf for how much (real cycles on this machine).

G23 † (the size of the prize)

$p = 0.78$. (a) Infinitely fast ($K\to\infty$): $S = 1/(1-0.78) = 1/0.22 = 4.5455\times$. (b) Exactly $4\times$: $S = 1/(0.22 + 0.78/4) = 1/(0.22+0.195) = 1/0.415 = 2.4096\times$. A week for $2.4\times$ on the 78% routine is clearly worthwhile (it more than doubles the whole program) — the point of computing the prize is precisely to distinguish this from optimizing a 4% routine. Confirmed by solve_g23.

G25 (sampling statistics)

At ~100 samples/s, a 0.3 s run yields ~30 samples total; a routine at 10% collects ~3 samples. Three samples carry enormous relative uncertainty ($\sqrt{3}\approx 1.7$, so roughly $\pm 60\%$) — the "10%" could easily read 3% or 20% on the next run. For ~1000 samples in that routine you need ~10,000 total samples, i.e. ~100 seconds of runtime (or a higher sampling rate / more runs). Lesson: a profile is only trustworthy if the run is long enough to gather many samples; profile a representative, sufficiently long workload.

H27 † (loop order in cache-line terms)

A cache line holds several consecutive real(dp) values. In column-major Fortran, u(i,j) and u(i+1,j) are adjacent in memory, so making the first index innermost accesses one line's worth of data in consecutive iterations — each line, once fetched, is fully used before eviction, so misses are rare. Second-index-innermost jumps a full column (many bytes) each iteration, touching one element per line and evicting lines before their other elements are used — a miss almost every iteration. The loop order is the cause; the D1 miss-rate difference in Part F is the measurement of that cause.

H29 (memcheck vs cachegrind)

memcheck (Ch. 13) detects correctness faults — invalid/uninitialised reads, leaks, out-of-bounds. cachegrind measures performance — cache references and misses via a simulated cache. You never run both in one invocation because valgrind runs exactly one tool per process (--tool= selects it), and each already slows the program 20–100×. For a correct but slow program you reach for cachegrind (memcheck has nothing to fix — there is no memory error, only a locality problem).


Chapter 29 — Answers to Selected Exercises (Optimization Techniques)

Full solutions to the odd-numbered and †-marked problems. Compilable ones are also in code/exercise-solutions.f90. All numeric outputs are hand-computed; all timing figures are illustrative (Tier 2) — "measure it yourself."

A1 † (loop order is bit-identical)

Both nests apply the identical per-element formula, so the interior Laplacian of $x^2+y^2$ is 4.00 everywhere (the five-point stencil is exact for quadratics), and max |fast - slow| = 0.0. On a $4000\times4000$ grid the j-inner ("slow") order strides a whole column per inner step, missing cache repeatedly, and would run several × slower (illustratively) — for the identical answer. Reordering a dependency-free loop changes speed, never result. (code/example-01-loop-order.f90.)

A3 † (blocked matmul; vary nb)

On the $4\times4$ test $A(i,k)=i$, $B(k,j)=j$ gives $C(i,j)=4ij$: rows 4 8 12 16 / 8 16 24 32 / 12 24 36 48 / 16 32 48 64, and max |blocked - matmul| = 0.0. Changing nb to 3 or 4 does not change the printed matrix. nb controls only the order in which the multiply-adds are grouped — and hence cache behaviour and speed on a large matrix — never which products are summed. With nb = n = 4 the "block" is the whole matrix, i.e., the ordered (unblocked) nest. (code/example-03-blocked-matmul.f90.)

B5 (fuse the two-pass, hoist the divide)

real(dp) :: c
c = k/dx**2                             ! division hoisted out of the loop
do i = 2, n-1
  y(i) = x(i) + c*(x(i-1) - 2.0_dp*x(i) + x(i+1))   ! compute AND apply in one sweep
end do

The whole grad2(:) temporary is gone (one pass, not two) and the divide runs once instead of per iteration. y is identical to the two-pass version.

B7 † (loop fission)

do i = 1, n
  s(i) = sqrt(x(i)*x(i) + y(i)*y(i))         ! clean: independent, unit stride -> vectorizes
end do
do i = 1, n
  if (s(i) > threshold) call record_outlier(i, s(i))   ! rare branch + call, now isolated
end do

Bought: the sqrt loop can vectorize at full SIMD width, because the un-inlinable call and the data-dependent branch that previously sat in the loop body (and blocked vectorization of the whole loop) are now in a separate loop. Cost: a second pass over s(:) — extra memory traffic and a second loop's overhead. Worth it when the sqrt work dominates and outliers are rare (the common case).

C9 (arithmetic intensity: stencil vs matmul)

(a) Stencil: ~5 flops per interior cell against ~5 real(dp) reads + 1 write ≈ 48 bytes → ~0.1 flop/byte → memory-bound; blocking a single sweep buys almost nothing. (b) Matrix multiply: $O(n^3)$ flops over $O(n^2)$ data, so each element is reused $O(n)$ times and intensity grows with $n$ → compute-bound; this is where blocking pays. Rule: block high-reuse/compute-bound kernels, not low-reuse/memory-bound ones.

C10 † (blocked = matmul for all nb)

For nb = 2, 3, 4 on a $6\times6$ matrix, every max |blocked - matmul| = 0 (with exact test data; code/exercise-solutions.f90 solve_c10 shows nb = 2, 3). On a large matrix nb matters for speed — the tile must be small enough to stay cache-resident to capture the reuse, and too-small tiles add loop overhead — but never for the answer, because blocking only regroups the (associative-up-to-rounding) multiply-adds. For general reals, regrouping can shift the last bit; for these integers it is bit-exact.

C11 (block-size back-of-envelope)

L1 = 32 KiB = 32768 bytes; real(dp) = 8 bytes. Three $b\times b$ tiles (one each of $A$, $B$, $C$) occupy $3b^2\cdot8$ bytes; requiring that $\le 32768$ gives $b^2 \le 1365$, so $b \lesssim 36$ — a tile up to about $32\times32$ fits L1 with headroom. The real best nb is found by measurement because the model ignores cache associativity and replacement, the prefetcher, register-level tiling, sharing L1 with other live data, and the L2/L3 levels that also want tiling. The estimate gives a ballpark; the hardware picks the winner.

C12 † (roofline crossover)

Machine balance = peak arithmetic ÷ bandwidth = $50\ \mathrm{Gflop/s} \div 20\ \mathrm{GB/s} = 2.5\ \mathrm{flop/byte}$: below this intensity a kernel is memory-bound, above it compute-bound. A kernel at $0.25\ \mathrm{flop/byte}$ is far below $2.5$ → memory-bound. Optimize its memory traffic (loop order, fusion, fewer passes); its arithmetic units are already idling behind memory latency, so making the arithmetic cleverer cannot help.

D13 (which loops vectorize)

(a) y(i) = 2*x(i) + z(i)yes: independent iterations, unit stride. (b) y(i) = y(i-1) + x(i)no: loop-carried dependency (each iteration needs the previous result — a scalar chain). (c) y(i) = x(i); if (mask(i)) call f(y(i))no: an un-inlinable call and a data-dependent branch block it (fission the clean part out). (d) total = total + x(i) — a reduction: the naive scalar chain has a dependency, but the compiler can vectorize it with partial sums (at -O3/with a reduction), reassociating the additions — so it is vectorizable if you accept last-bit reassociation.

D15 † (do concurrent + break the promise)

do concurrent (j = 2:ny-1, i = 2:nx-1)
  u_new(i,j) = u(i,j) + rx*(u(i-1,j) - 2*u(i,j) + u(i+1,j)) + ry*(u(i,j-1) - 2*u(i,j) + u(i,j+1))
end do

Promise: no iteration depends on another; the iterations may run in any order, or all at once. One-line break: write the result back into u instead of u_new (u(i,j) = u(i,j) + ...). Now an iteration may read a neighbour that another iteration has already overwritten — a loop-carried dependency — so the promise is false and the result is undefined (and would silently become a Gauss–Seidel sweep even run serially).

E17 † (optimize step + verification harness)

The optimized step is the Project Checkpoint's stencil: correct loop order, fused Laplacian+update, hoisted rx/ry, expressed as do concurrent (code/project-checkpoint.f90). Harness:

call random_number(u);  u_ref = u;  u_opt = u
call step_ch24(u_ref, ...)          ! Chapter 24 version
call step_opt (u_opt, ...)          ! this chapter's version
if (maxval(abs(u_opt - u_ref)) /= 0.0_dp) error stop 'optimization changed the answer!'

Exactly zero (not a tolerance) is right because the stencil is dependency-free: both versions evaluate the identical arithmetic expression at each cell, and reordering independent iterations reassociates no sum — so the results are bit-for-bit equal. A tolerance would be the correct choice only for a reduction (E19).

E19 (two verifications)

! dependency-free stencil: bit-exact
if (maxval(abs(u_opt - u_ref)) /= 0.0_dp) error stop 'stencil mismatch'
! reduction (total heat): tolerance, because SIMD partial sums reassociate the additions
tol = 8.0_dp * real(size(u), dp) * epsilon(1.0_dp) * abs(sum_ref)
if (abs(sum_opt - sum_ref) > tol) error stop 'sum mismatch'

Justification: summing $N$ terms accumulates a relative rounding error on the order of $N\varepsilon_{\text{mach}}$; reassociating the sum (as vectorization does) changes which roundings occur but keeps the error within that bound, so a tolerance a small multiple of $N\varepsilon_{\text{mach}}|\text{sum}|$ is both safe and tight. (Machine epsilon: Chapter 20.)

F20 † (port NumPy row-major → Fortran column-major)

do j = 2, m-1                 ! Fortran: the FIRST index must be innermost
  do i = 2, n-1
    u_new(i,j) = u(i,j) + c*(u(i-1,j) - 2.0_dp*u(i,j) + u(i+1,j))
  end do
end do

NumPy is row-major, so its contiguous (fast) axis is the last one, and the colleague's inner loop over the last index was cache-friendly there. Fortran is column-major, so the contiguous axis is the first one, and the inner loop must run over the first index. Same physics; the fast axis is mirrored. (This is the exact hazard at the f2py boundary — Chapter 15.)

F21 (modernize + reorder the F77 matmul)

c = 0.0_dp
do j = 1, n
  do k = 1, n
    do i = 1, n           ! i innermost -> unit stride down columns of c and a
      c(i,j) = c(i,j) + a(i,k)*b(k,j)
    end do
  end do
end do

i becomes innermost (was outermost). The F77 order had k innermost, so a(i,k) strode across a row (column-major: n elements per step) — cache-hostile. The reordered nest walks c(:,j) and a(:,k) with unit stride. Modern style adds implicit none, real(dp), and intent. The result is unchanged (the same multiply-adds, regrouped).

G22 † (memory traffic: two-pass vs fused)

For $n = 1000$, one real(dp) field is $n^2\cdot8 = 8\ \mathrm{MB}$. The two-pass step additionally writes the whole lap array and reads it back — an extra $2\times8 = 16\ \mathrm{MB}$ of traffic per step that the fused version never does. If the fused step moves roughly the field in + the field out ($\approx 16\ \mathrm{MB}$), removing the temporary's $16\ \mathrm{MB}$ is on the order of a ~40–50% traffic reduction. Because the kernel is memory-bound (runtime $\propto$ bytes moved), that fraction directly predicts the speedup.

G23 (SIMD ceiling)

A 256-bit AVX register holds $256/64 = 4$ real(dp) values; AVX-512 holds $512/64 = 8$. Ideal speedups are therefore $4\times$ and $8\times$. You will not reach them because: (1) a memory-bound loop is limited by bandwidth, not lane count — more lanes just idle waiting for data; (2) not all work vectorizes cleanly (remainder iterations, reductions, non-unit stride); (3) some CPUs downclock under heavy AVX-512; (4) latency and dependencies limit throughput. The lane count is a ceiling, not a promise.

G24 † (divisions saved by hoisting)

Interior points: $498\times498 = 248{,}004$. The naive stencil does 2 divisions ($1/dx^2$, $1/dy^2$) per interior point per step; hoisting computes them twice per step (or once total) instead. Saved per step $\approx 2\times248{,}004 - 2 \approx 496{,}006$ divisions; over $10^4$ steps, $\approx 4.96\times10^{9}$ — roughly five billion divisions removed by a one-line change. Emphatically worth it (a divide is many times a multiply's cost).

H25 (Ch. 5 — fix the loop nest)

The inner loop runs over the second index j, striding a whole column each step (column-major hostile). Fix — inner over the first index:

do j = 1, n
  do i = 1, n           ! a(i,j) and a(i+1,j) adjacent in memory -> unit stride
    a(i,j) = b(i,j) + c(i,j)
  end do
end do

(Or, better, the whole-array a = b + c, which states independence outright and vectorizes.)

H27 † (Ch. 28 — which optimization first, and why not I/O)

Apply the loop-order fix first: 96% of time is in step, and the high last-level-cache miss rate is the fingerprint of a strided, column-major-hostile access — the biggest, cheapest win for a memory-bound stencil. Do not touch the I/O: it is 3% of runtime, so by Amdahl's law even eliminating it entirely caps the total speedup at ~3%. The profile is telling you exactly where the time is not; optimizing the I/O would be effort spent off the hot path.

H28 (Ch. 21 — dgemm vs your blocked loop)

Your blocked loop at ~8% of peak versus dgemm at ~80% is roughly a 10× speedup from switching. dgemm does three things your loop does not: (1) blocks for every cache level at once with per-architecture tile sizes; (2) uses a hand-written assembly microkernel with explicit SIMD and software pipelining to hide latency; (3) packs tiles into contiguous buffers for guaranteed unit stride and TLB friendliness. The one-line conclusion of §29.5: for dense linear algebra, recognize the shape and call the tuned BLAS — never hand-roll dgemm.


Chapter 30 — Solutions to Selected Exercises (†-marked and odd-numbered)

Compilable solutions to the code-based problems (5, 15) are in code/exercise-solutions.f90. All timing figures below are illustrative (Tier 2); numbers are shape, not measurement.

1. †⭐ Opt levels match. Prediction: all three builds (-O0, -O2, -O3 -march=native) print the same value, 385.00. They agree because -O0 through -O3 are result-preserving — they change how fast, not what, the program computes. Rule: optimization levels -O0-O3 change speed, not answers. (Only -Ofast/-ffast-math can change the answer.)

3. †⭐⭐ -Ofast on example-02. Regardless of what your particular gfortran chose to print, -Ofast is permitted to reassociate (a + b) + c into a + (b + c) — because it enables -ffast-math and -fno-protect-parens. That regrouping evaluates (-1e20 + 1), which rounds to -1e20 (the 1 is lost), then 1e20 + (-1e20) = 0.0. So -Ofast may print 0.00 where -O2 prints 1.00. The point is not which one you got; it is that -Ofast removes the guarantee, so you can no longer rely on the IEEE result.

5. †⭐⭐ Predict the guess output. Prints s == 1.0? F. Summing 0.1 ten times is not exactly 1.0 in binary floating point, because 0.1 is not representable; the accumulated sum is about 0.9999999999999999. This is not an optimization bug — every -O level gives the same result — it is floating-point equality, the subject of [Chapter 20]. Fix: never test floats with ==; compare within a tolerance, e.g. abs(s - 1.0_dp) < 1.0e-12_dp. (Code in exercise-solutions.f90.)

7. †⭐⭐ gfortran → ifx, matching optimization and FP. ifx -O3 -xHost -ipo -fp-model precise. The extra flag with no counterpart in the gfortran line is -fp-model precise. It is needed because ifx defaults to a relaxed floating-point model (-fp-model fast), whereas gfortran defaults to strict IEEE; without precise, the ifx build can drift from the validated gfortran results in the last digits.

9. †⭐ LTO link failure. The second command omitted -flto (and -O3) at the link step, so link-time optimization information is not consumed consistently. -flto must appear on both the compile and the link commands. Fix: gfortran -std=f2018 -O3 -flto solver.o main.f90 -o app.

11. †⭐⭐ "1000× slower than the paper." The build line is a development build — -O0 -g -fcheck=all -fbacktrace. The run-time checks and total absence of optimization make it many times slower than a release build; they are measuring the debugging safety net, not the algorithm, and it is not a language problem. Corrected timing command: gfortran -std=f2018 -O3 -march=native -flto solver.f90 -o solver (checks removed), then time that.

13. †⭐⭐ Portable Makefile. Select flags per compiler and state the FP model explicitly on each; keep two profiles. Sketch:

# release, chosen by compiler identity:
FFLAGS_gfortran = -O3 -march=native -flto            # gfortran's FP default is already strict
FFLAGS_ifx      = -O3 -xHost -ipo -fp-model precise  # precise = restore IEEE strictness
# debug (either compiler): -g -O0 + that compiler's bounds-check + traceback flags

The original hardcoded FC = ifort (won't parse under gfortran) and -fp-model fast (silently relaxes IEEE). Replacing fast with precise is what makes results reproducible across the two compilers.

15. †⭐⭐ Provenance banner. A subroutine that prints compiler_version() and compiler_options(), called once at program startup, before the computation, so every run's log begins with its own build recipe. Full compilable version is ex15_provenance_banner/print_provenance in exercise-solutions.f90.

17. ⭐⭐⭐ PGO for the solver. Three commands: -fprofile-generate build → run on representative input (writes .gcda) → -fprofile-use rebuild. It will help the branchy parts — the per-step boundary- condition dispatch and any I/O/checkpoint logic — and will do little for the branch-free five-point stencil that dominates the runtime. "Representative input" means a config whose grid size, boundary types, and step count match production runs (profiling on a toy 5×5 case would mis-guide the optimizer). Measure by timing release+PGO against release-without-PGO on the same workload (Chapter 28 methodology), confirming the checksum is unchanged; keep PGO only if the gain clears the extra build complexity.

18. †⭐⭐⭐ Bit-exact across two CPUs. Avoid or pin the flags that let the two machines choose different instructions: replace -march=native with a single common baseline -march= both CPUs support; forbid fused multiply-add so a*b + c is not fused on one CPU and split on the other (per the §30.1 FMA note, FMA rounds once vs twice → last-bit differences); use no -Ofast; and set the same explicit FP model on both. The trade: you give up the widest SIMD and FMA — real speed — to buy identical bits. For most science, agreement to a tolerance is the better target; demand bit-exactness only when a downstream test truly requires it.

19. †⭐ SIMD width ceiling. 128-bit vectors process 2 real(dp) per instruction; 512-bit process 8. The ceiling from width alone is 8 / 2 = 4×. The real speedup is usually much less because (a) the loop may be memory-bound (Chapter 28) — waiting on the memory bus, so wider arithmetic units sit idle; (b) loop overhead and a non-vectorized remainder dilute the gain; (c) other parts of the program do not vectorize at all (Amdahl, foreshadowing Chapter 31).

21. †⭐⭐ Is PGO worth it? Run once: PGO costs ~3× the build time for a 4% runtime gain — not worth it; the one-time build overhead dwarfs the single-run saving. Run 10,000× (a parameter sweep): the 4% saving is paid on every one of 10,000 runs while the PGO build is paid once, so it pays for itself many times over. Crossover reasoning: PGO is worth it when one-time extra build cost < (per-run saving) × (number of runs) — amortize the fixed cost over the run count.

23. †⭐⭐ Why -Ofast changes a sum but not a count. -ffast-math exploits that floating-point addition is non-associative: each addition rounds, so regrouping (a+b)+c as a+(b+c) can land on a different rounded value. Integer arithmetic is exact and associative — no rounding occurs — so reassociating an integer count cannot change it. Hence -Ofast can perturb a floating sum's digits but never an integer tally. (Overflow aside, which is a separate matter.)

25. †⭐⭐ Does -O3 fix a bad memory pattern? No. A loop that walks a column-major array in row-major order cache-misses on nearly every access, and those misses dominate the runtime regardless of -O level; a flag cannot reorder your algorithm's memory access. You fix the access pattern in the source (loop order, Chapter 29); then the flags in this chapter multiply the already-cache-friendly code. Flags amplify good structure; they do not substitute for it. (This is the Chapter 27 lesson: a 10× from loop order alone, at a fixed -O level.)

27. ⭐⭐⭐ CI matrix. Two compilers (gfortran, ifx) × two profiles (debug, release) = four cells. Debug/gfortran and debug/ifx catch bounds/allocation errors and standard violations — and each compiler warns about different things, so two catch more than one. Release/gfortran and release/ifx catch optimizer-exposed undefined behavior and floating-point-default mismatches (the Intel relaxed default). "Builds under gfortran only" misses ifx's distinct warnings, the FP-default drift, and nonportable gfortran-isms — latent bugs that a new platform would otherwise reveal at the worst possible moment.

28. †⭐⭐⭐ Flags policy (one page). - Development build: -g -O0 -fcheck=all -fbacktrace — catch mistakes early; debuggable. - Release build: -O3 -march=native -flto, checks removed — speed for real runs. - -Ofast rule: use only after the validation suite still passes at tolerance, and record that you used it — it relaxes IEEE and can change results. - -march=native rule: never bake it into shipped build files; build on the target node or pick a conservative baseline -march= — portability. - Recording rule: every published number carries the compiler + version, the full flag list, and the machine/CPU — reproducibility. Each line is justified by a single failure it prevents: silent wrong results, unrunnable binaries, irreproducible benchmarks.


Chapter 31 — Why Parallel?

Solutions to the daggered (†) and odd-numbered problems. Computational answers are also worked as runnable, hand-checked code in code/exercise-solutions.f90. Design problems (31.21, 31.24) give a model answer; reasoned variants are fine.

31.1 Dennard scaling ended around 2005 and is what gave us the free lunch: it held power density roughly constant as transistors shrank, so clock frequency could rise "for free" with each generation. Moore's Law — the doubling of transistor count — continued. Confusing the two is the most common error in this story: the transistors kept coming; what stopped was the ability to clock them faster within the power budget.

31.3 One core out of 64, so roughly $1/64 \approx 1.6\%$ of the node's compute capability. "The free lunch is over" because the hardware will not make your serial program faster on its own anymore — the other 63 cores sit idle, and the only way to use them is to parallelize deliberately. You bought a team of oxen and hitched one.

31.5 $p = 0.75$, so serial fraction $1 - p = 0.25$. (a) Ceiling $S_{\max} = 1/(1-p) = 1/0.25 = 4\times$. (b) On 4 cores, $S(4) = 1/(0.25 + 0.75/4) = 1/(0.25 + 0.1875) = 1/0.4375 = 2.2857\times$. Note that a quarter of the work being serial already caps you at 4×.

31.7 $p = 0.90$. (a) $S(8) = 1/(0.10 + 0.90/8) = 1/(0.10 + 0.1125) = 1/0.2125 = 4.7059\times$; $S(1000) = 1/(0.10 + 0.90/1000) = 1/(0.10 + 0.0009) = 1/0.1009 = 9.9108\times$. (b) The ceiling is $1/0.10 = 10\times$. By 8 cores you already have 4.7×; the parallel term $p/N$ is what shrinks with more cores, but the serial 0.10 is fixed, so once $p/N$ is small the serial term dominates the denominator. Multiplying cores by 125 (from 8 to 1000) drove $p/N$ from 0.1125 down to 0.0009 — a change swamped by the constant 0.10 — so the speedup barely moved from 4.7× to 9.9×. The serial fraction is the tyrant.

31.9 Ceilings $S_{\max} = 1/(1-p)$: $p=0.90 \to 10\times$; $p=0.95 \to 20\times$; $p=0.99 \to 100\times$; $p=0.999 \to 1000\times$. Each 10-fold reduction in the serial fraction (0.10 → 0.01 → 0.001) multiplies the ceiling tenfold. The lesson: an HPC engineer's effort is far better spent shrinking the serial fraction (the last stubborn percent) than adding cores, because the ceiling depends only on $1-p$, not on $N$.

31.10 Gustafson with $s = 0.10$ on 16 processors: $S = s + (1-s)N = 0.10 + 0.90 \times 16 = 14.5\times$. Amdahl at the same fraction ($p = 0.90$) and 16 cores: $S = 1/(0.10 + 0.90/16) = 1/(0.10 + 0.05625) = 1/0.15625 = 6.4\times$. They differ (14.5 vs 6.4) because Gustafson lets the problem grow with the cores — the parallel work scales up while the serial part stays fixed — whereas Amdahl keeps the problem fixed, so the serial 10% caps it near 10×.

31.11 Amdahl assumes a fixed problem: as $N$ grows the parallel time $p/N \to 0$, leaving only the fixed serial $1-p$, so the speedup is trapped below $1/(1-p) = 10\times$. Gustafson assumes the problem grows with the cores so each core keeps a full workload: the parallel work scales up with $N$ while the serial work stays about the same absolute size, so the serial fraction shrinks and the scaled speedup climbs linearly without a ceiling. Same 10% serial, opposite conclusions — because one holds the problem size fixed and the other grows it.

31.12 40× on 64 cores. (a) Efficiency $E = S/N = 40/64 = 0.625 = 62.5\%$. (b) Karp–Flatt: $e = (1/S - 1/N)/(1 - 1/N) = (1/40 - 1/64)/(1 - 1/64) = (0.025 - 0.015625)/0.984375 = 0.009375/0.984375 = 0.0095$, i.e. about 0.95% serial. (c) Yes, 40× on 64 cores is a strong result: 62.5% efficiency at 64 cores is far better than most fixed-problem codes manage (compare the 24% of a 95%-parallel code at 64 cores), and it implies a very small serial fraction (~1%).

31.13 (a) Strong scaling — the total problem ($4096 \times 4096$) is fixed while cores increase; you are asking Amdahl's question and hoping the time falls. (b) Weak scaling — the work per core (a $512 \times 512$ tile) is fixed while the total grid and the core count grow together; you are asking Gustafson's question and hoping the time stays flat.

31.14 (a) Shared memory, OpenMP (or coarrays) — one node's cores, grid in RAM, least ceremony. (b) Distributed memory, MPI (or coarrays) — a cluster far bigger than one node. (c) GPU/ accelerator, OpenACC (or CUDA Fortran) — a massively data-parallel stencil on a graphics processor. (d) Coarrays — Fortran's native, standardized parallel model, no external library.

31.15 (a) data parallelism (same stencil, every cell); (b) task parallelism (two different jobs — read and compute — at once); (c) data parallelism (same filter, every pixel); (d) task parallelism (grill, plate, wash — three distinct jobs concurrently).

31.16 Coarrays implement a partitioned global address space (PGAS): the program is written with one notation (a coindexed reference like u(i,j)[q]), and the compiler and runtime decide, per access, whether reaching image q's data is a local memory read (shared memory, same node) or a network message (distributed memory, another node). Because that decision is hidden below the notation, the same coarray code runs on both kinds of hardware — which is why coarrays sit in both rows of the taxonomy.

31.17 Wrong: an 80%-parallel kernel cannot reach 80× — Amdahl caps it. The ceiling is $1/(1 - 0.80) = 1/0.20 = 5\times$, on any number of cores. At 100 cores specifically, $S(100) = 1/(0.20 + 0.80/100) = 1/(0.20 + 0.008) = 1/0.208 = 4.81\times$. So the honest prediction is under 5×, and the extra 95 cores past the first few are nearly wasted. The teammate has confused the core count with the speedup.

31.19 The pfrac / real(ncores, dp) is a red herring: pfrac is real(dp), so even pfrac / ncores would promote the integer to real and divide correctly — and the code already casts with real(ncores, dp) for clarity, so there is no integer-division bug. The real bug is the missing reciprocal: the function computes the time $T(N) = (1-p) + p/N$ and returns it as if it were the speedup. Speedup is the reciprocal of that time. For $p = 0.90$, $N = 8$ it returns $0.10 + 0.1125 = 0.2125$ (the time) instead of $1/0.2125 = 4.71$ (the speedup). Fix:

s = 1.0_dp / ((1.0_dp - pfrac) + pfrac / real(ncores, dp))

31.21 (model — the five-step plan for the heat solver.) 1. Fast serial first. Ensure the stencil sweep uses column-major loop order and is vectorized/tuned (Chapters 27, 29) and built with good flags (Chapter 30) — because parallel speedup is measured against the serial baseline and capped by the serial fraction. 2. Profile. Confirm (Chapter 28) that essentially all the time is in the time-stepping loop's stencil sweep; setup and periodic I/O are the small serial remainder. 3. Identify the parallelism and the dependency. The interior stencil update within one step is data-parallel (each cell's new value comes from old neighbours — independent). The time loop is sequential: step $n+1$ needs step $n$'s finished field, so you parallelize within a step, never across steps. 4. Estimate with Amdahl. From the profiled serial fraction (≈2%), the ceiling is ~50× and 8–16 cores give an efficient 7–12× — decide whether that payoff justifies the port before writing it. 5. Choose the model and scale. One node → OpenMP/coarrays; a cluster → MPI/coarrays with domain decomposition; a GPU → OpenACC. Decide strong (fixed plate, faster) vs weak (finer plate, same time).

31.23 You want $S \geq 30$ on $N = 64$. The largest tolerable serial fraction is the Karp–Flatt value at that target: $e = (1/30 - 1/64)/(1 - 1/64) = (0.033333 - 0.015625)/0.984375 = 0.017708/0.984375 = 0.0180$. So your program can be at most about 1.8% serial and still reach 30× on 64 cores. (Check: with $p = 0.982$, $S(64) = 1/(0.018 + 0.982/64) = 1/(0.018 + 0.015344) = 1/0.033344 \approx 30$.)

31.25 Because parallel speedup is measured against the serial baseline and is capped by the serial fraction (Amdahl). If the stencil sweep runs against the column-major grain (Chapter 27), it is cache-hostile and slow; parallelizing it just spreads a slow computation across cores. Worse, an un-optimized serial bottleneck inflates the serial fraction $1-p$, which lowers the Amdahl ceiling for the whole program. Fixing the loop order first makes the hot spot fast and keeps the serial fraction small, so the eventual parallel speedup is both larger and more efficient. Make the serial fast, then parallelize it.

31.27 The update uses two arrays — the current field and the next field — and computes each interior cell's new value purely from the old (current) values of its four neighbours (Chapters 5, 24). Because no interior update reads another interior update's new value, all the interior updates of a single step are mutually independent and could run simultaneously — "embarrassingly parallel." If the update instead read new neighbour values as it swept (updating in place, Gauss–Seidel style), each cell would depend on neighbours already updated in the same sweep: a sequential dependency that makes the result order-dependent and destroys the easy parallelism. The two-array structure is what buys the parallelism.

31.28 (program — Karp–Flatt from a measured speedup.)

program karp_flatt_calc
  use, intrinsic :: iso_fortran_env, only: dp => real64
  implicit none
  real(dp) :: s, e
  integer  :: n
  s = 15.4217_dp
  n = 64
  e = (1.0_dp / s - 1.0_dp / real(n, dp)) / (1.0_dp - 1.0_dp / real(n, dp))
  print '(a, f8.4)', 'Karp-Flatt serial fraction e = ', e
end program karp_flatt_calc
! Expected output:
! Karp-Flatt serial fraction e =   0.0500

Testing with $S = 15.4217$, $N = 64$ recovers $e = 0.0500$ — the 5% serial fraction of the 95%-parallel program from §31.2, confirming the metric inverts Amdahl's Law correctly. A rising value of $e$ across increasing $N$ would mean the limiting cost is not a fixed serial section but parallel overhead — communication, synchronization, or load imbalance — that grows with the core count (see Case Study 1).


Chapter 32 — Coarrays

Solutions to the daggered (†) and odd-numbered problems. Computational answers are also worked as a runnable, hand-checked coarray program in code/exercise-solutions.f90. Design problems (32.21, 32.23, 32.24) give a model answer; reasoned variants are fine.

32.1 An image is one of the several concurrent instances of a coarray program, each running the same executable with its own private copy of every variable. this_image() returns the calling image's number (1..N); num_images() returns the total number of images N. SPMD = Single Program, Multiple Data: every image runs the identical program text but on its own share of the data. An image uses the two intrinsics to compute its share — e.g. with me = this_image() and ni = num_images(), it owns work items (me-1)*chunk+1 .. me*chunk where chunk = total/ni. Identity in, work-share out.

32.3 Different images execute concurrently and share one terminal with no ordering referee, so the lines they print can appear in any order or even interleave — the standard makes no guarantee about inter-image output order. The idiom for deterministic, single-source output is to designate one image (conventionally image 1) to do all the printing, after a synchronization (sync all) guarantees the data it prints has been produced by the other images.

32.5 a = this image's own copy of the whole 50-element array (local, a[this_image()]). a(10) = the 10th element of this image's copy (local). a[3] = image 3's copy of the whole array (coindexed — may cross the network). a(10)[3] = the 10th element of image 3's copy (coindexed — may cross the network). The two unbracketed forms (a, a(10)) are always local and fast; the two coindexed forms (a[3], a(10)[3]) reach another image and, on a cluster, may be a network message.

32.7 this_image(grid) returns the calling image's cosubscripts — its position in the coarray's image grid, a small integer vector like [2,3] for a corank-2 [np,*] coshape — rather than the single scalar image number that this_image() (no argument) returns. image_index(grid, [2,3]) does the inverse: it maps the cosubscript position [2,3] back to the plain image number of the image sitting at that grid position. They are inverses because one converts image-number → grid-position and the other grid-position → image-number, so you can move freely between "which image" and "where in the image grid."

32.9 In the language of segments, image control statements (like sync all) divide each image's execution into segments, and the standard guarantees ordering only between segments that a synchronization connects. In the gather, every image defines its coarray in one segment and image 1 references those coarrays (sq[q]). Without a sync all between them, image 1's reads and the other images' writes fall in segments that are not ordered with respect to each other — the standard declares the program undefined (a race): image 1 could read sq[3] before image 3 has written it, getting an undefined value. The sync all orders the write-segment before the read-segment, making the reads safe.

32.11 sync all is a global barrier — every image waits for every other, even ones with nothing to exchange. sync images(image-set) synchronizes the executing image only with the listed images (pairwise), letting the rest run ahead; use it for point-to-point coordination (a producer signalling specific consumers, a pipeline stage waiting for its predecessor) to avoid the cost of a global barrier. Its risk: the matching must line up exactly — each sync images(A) on one image must be met by a corresponding sync images on the partner — or images deadlock waiting for a signal that never comes; sync all cannot deadlock this way because everyone participates identically.

32.11 (the counter version, if assigned) Correct shared accumulate:

integer :: total[*]
if (this_image()==1) total[1] = 0
sync all
critical
  total[1] = total[1] + local_count      ! one image at a time; no interleaving
end critical
sync all
if (this_image()==1) print *, total[1]

Without the critical, even with a sync all, two images can both read the same old total[1], both add their value, and both write back — the second overwrites the first, losing an update. A barrier orders segments but does not make a read-modify-write atomic; mutual exclusion does. (Better still: co_sum.)

32.13 After co_sum(x), the result (the sum of every image's pre-call x) is written back to x on every image. After co_sum(x, result_image=1), the sum is placed on image 1 only, and the other images' x is left undefined/unchanged. They are the analogues of MPI's MPI_Allreduce (result on all ranks) and MPI_Reduce (result on the root only), respectively.

32.15 call co_broadcast(x, source_image=3) copies the value that x holds on image 3 into x on every image, so that afterward all images share image 3's value. Every image must call it — not just image 3 — because it is a collective: it coordinates all images into one operation, and an image that failed to call would leave the others waiting (and the operation ill-defined). The source image supplies the data; the others supply their participation and receive the result.

32.17 The broadcast is a race with no synchronization: image 1 writes cfg and the other images read cfg[1] in segments that are not ordered, so a reader may see cfg[1] before image 1's write lands — undefined. The fix is either a synchronization between the write and the reads, or, far better, the collective:

integer :: cfg[*]
if (this_image()==1) cfg = 7
call co_broadcast(cfg, source_image=1)   ! correct, synchronized, one line

(With the hand-rolled version you would need if (this_image()==1) cfg = 7, then sync all, then if (this_image()/=1) cfg = cfg[1] — but co_broadcast is the right tool.)

32.19 The right halo (local column nloc+2) should receive the right neighbour's first owned column, which is that neighbour's local column 2, not nloc+1. nloc+1 is the neighbour's last owned column, so the code copies the wrong column and the stencil at the strip's right edge reads a value one column too far into the neighbour. Correct: u(:, nloc+2) = u(:, 2)[me+1]. (Symmetrically, the left halo reads the left neighbour's last owned column, local nloc+1: u(:, 1) = u(:, nloc+1)[me-1].)

32.21 (model — remainder decomposition.) A coarray's local shape must be identical on every image, so you cannot simply give some images one more column than others in the same coarray. Two standard fixes: (1) Pad to the maximum. Compute nloc = ceiling(ncol_int / ni), allocate every image u(nx, nloc+2)[*], and have the images that own fewer real columns leave their surplus columns inactive (never updated, excluded from the gather). You track each image's true owned count my_cols (e.g. nloc for the first mod(ncol_int,ni) images and nloc-1 for the rest, or a block-cyclic map) and update/exchange/gather only 2 : my_cols+1. (2) Compute per-image bounds explicitly from me and ni (lo, hi global column indices via a balanced partition) and size a local (non-coarray) work array to fit, using a coarray only for the halo columns that must be shared. The key constraint to reconcile: identical coshape/local shape across images (so u(:,k)[q] is meaningful) versus unequal real work — solved by padding to a common shape and bookkeeping the true bounds.

32.23 (model — 2D tiling.) Declare a corank-2 coarray real :: u(txp2, typ2)[np, *], laying the images out as an np × (ni/np) grid of tiles (each tile tx×ty interior cells plus a one-cell halo ring, hence +2 in each dimension). Each interior tile now exchanges four halos — with its north, south, east, and west neighbours (the four edge rows/columns) — instead of the strip's two; corner cells may also need a diagonal exchange for a 9-point stencil, but the 5-point stencil needs only the four edges. image_index(u, [i,j]) maps a tile's grid position [i,j] to the image number to coindex, and this_image(u) gives an image its own [i,j]. The trade-off: 2D tiling has a smaller halo-to-interior ratio at high core counts (surface $\sim 4\sqrt{N^2/P}$ vs the strip's $2N$), so it scales further, at the cost of more neighbours and more bookkeeping.

32.25 (Ch. 31.) Chapter 31 estimated the solver at $p \approx 0.98$ parallel, ceiling $1/(1-p) = 50\times$, with the stencil sweep the data-parallel hot spot and the time loop a hard sequential dependency. The coarray checkpoint realizes exactly that plan: the data-parallel part is the stencil update, which every image applies to its own strip simultaneously — the same operation on different columns. The sequential structure is the do step loop with two sync all per step: the images synchronize and exchange halos every step and cannot begin step $n+1$ until step $n$ has completed and committed everywhere. We parallelize within a step (across strips) and march the steps in order — Chapter 31's prescription made into code. (The serial fraction Amdahl warned about lives in the gather/print and the per-step sync overhead.)

32.27 (Ch. 5 + 24.) Fortran stores arrays in column-major order (Chapter 5): the first subscript varies fastest through memory, so a whole column u(:,j) occupies consecutive memory addresses — it is contiguous. A halo that is a column is therefore one contiguous block, which the runtime can move as a single efficient transfer (one message on a cluster). If you cut the plate along rows instead, a halo would be a row u(i,:), whose elements are strided nx apart in memory — non-contiguous — forcing a gather-scatter or many small transfers for the same bytes. Cutting along columns aligns the decomposition with the memory order, so both the local stencil sweep (Chapter 27) and the communication stay contiguous. The column-major lesson from Part I pays off twice.

32.28 (program — distributed sum of squares.) See code/exercise-solutions.f90. Each image owns a contiguous chunk of the integers $1..12$, sums $i^2$ over its chunk into a local ssq, and co_sum(ssq) combines the partials; the result is $\sum_{i=1}^{12} i^2 = 12\cdot13\cdot25/6 = 650$ on every image, for any image count that divides 12 (1, 2, 3, 4, 6, 12). E.g. on 4 images the partials are $14, 77, 194, 365$, summing to $650$. Image-count independence is the signature of a correct reduction because the mathematical answer does not depend on how the data was partitioned; if the result changed with the number of images, the reduction would be losing or double-counting contributions — a race or a decomposition bug (exactly the fault of Case Study 1's buggy_norm).


Chapter 33 — OpenMP

Solutions to the daggered (†) and odd-numbered problems. Compilable versions of the code-based solutions (33.7, 33.8, 33.14, 33.16, 33.25) are in code/exercise-solutions.f90.

33.1 A program runs serially on the master thread from the top. At !$omp parallel it forks a team (the master plus additional threads); every thread in the team executes the region. At !$omp end parallel the team joins — the extra threads go dormant — and the master alone continues. Between two parallel regions exactly one thread runs (the master); parallel regions are islands of many threads in serial execution.

33.3 omp_get_thread_num() returns the calling thread's identifier, an integer from 0 to omp_get_num_threads() - 1; omp_get_num_threads() returns the number of threads in the current team. The master thread's id is 0. On a team of 6 the ids are 0, 1, 2, 3, 4, 5. Both require use omp_lib.

33.5 With OMP_NUM_THREADS=3: Deterministicstart prints first (serial master), end prints last (serial master), and exactly three thread k lines appear, one per thread, with k taking each of the values 0, 1, 2 once. Nondeterministic — the order of the three thread lines (and they may interleave mid-line). One possible run:

start
thread 0
thread 2
thread 1
end

A different run may print the middle three in any order.

33.7 5050.00, and it does not depend on the thread count. The reduction(+:s) gives each thread a private partial sum (starting at 0) and combines them correctly at the end; with integer-valued inputs every partial sum is exact in floating point, so the result is bit-for-bit 5050.00 on 1, 8, or 64 threads. (Sum of 1..100 = 100·101/2 = 5050.) Compilable in code/exercise-solutions.f90 (ex_33_07_reduction_sum).

33.8 Two variables are used in the region but not scoped: the inner loop index j and the per-row scratch rowsum. Under default(none) the code will not compile until both are scoped — that is the seat belt doing its job. Both must be private. Why each missing one is a race: each thread handles distinct rows i (the outer index is auto-private), but a single shared j and a single shared rowsum would be written by all threads at once — thread A's rowsum for its row would be corrupted by thread B accumulating its own row into the same location, giving run-to-run garbage. Corrected directive:

!$omp parallel do default(none) shared(a, nr, nc) private(i, j, rowsum)

(with nr, nc shared if they are variables, or unlisted if they are parameters). Deeper lesson: the !$omp do (outer) index is predetermined private for you; inner indices and every scratch temporary are your responsibility. Fixed program in code/exercise-solutions.f90 (ex_33_08_row_normalize).

33.9 s = s + x(i) is not atomic; the processor reads s, adds x(i), then writes s. With s shared and unsynchronized, two threads can interleave: both read the same old s, both add their term, and the second write overwrites the first — so one contribution is lost. Which contributions are lost depends on thread timing, so the total is wrong and changes each run. The one-clause fix is to replace shared(s) (implicit or explicit) with reduction(+:s), which gives each thread a private accumulator combined correctly at the end.

33.10 With no enclosing !$omp parallel`, the `!$omp do has no team to share iterations among, so the loop simply runs serially on the master thread — no error, no warning, no speedup. The construct shares work among a team; absent a team there is nothing to share. Two fixes: (a) use the combined !$omp parallel do, which creates the team itself; or (b) wrap the loop in an explicit !$omp parallel … !$omp end parallel around the !$omp do.

33.11 Attributes: iprivate (loop index); a, bshared (read only); cshared (written, but at the distinct index i — no conflict); scaleshared (read only); nshared if a variable (or unlisted if a parameter); tmpprivate (scratch, rewritten each iteration); totalreduction(+:total) (a running sum). Complete directive:

!$omp parallel do default(none) shared(a, b, c, scale, n) private(i, tmp) reduction(+:total)

33.13 Rewritten with a reduction:

count = 0
!$omp parallel do default(none) shared(v, n) private(i) reduction(+:count)
do i = 1, n
  if (v(i) > 0.0_dp) count = count + 1
end do
!$omp end parallel do

(n shared if a variable, else unlisted.) The reduction is faster. The atomic version synchronizes on every positive element — a hardware-atomic write to one shared count, and if many elements are positive, heavy contention plus false sharing on that one cache line. The reduction accumulates into a private per-thread counter with no per-update synchronization and combines once at the end, so there is no contention and no false sharing. Prefer reduction > atomic > critical.

33.14 Convergence check with a max reduction:

dmax = 0.0_dp
!$omp parallel do default(none) shared(u_old, u_new, nx, ny) private(i, j) reduction(max:dmax)
do j = 2, ny-1
  do i = 2, nx-1
    dmax = max(dmax, abs(u_new(i,j) - u_old(i,j)))
  end do
end do
!$omp end parallel do

reduction(max:dmax) is right because each thread needs its own running maximum (private copy initialized to -huge, the identity for max, so it never spuriously wins), combined by max at the end — race-free and deterministic. A shared dmax updated with max would be a race exactly like the sum. Compilable 1-D version (result 5.00) in code/exercise-solutions.f90 (ex_33_14_max_change).

33.15 Hoist the parallel region outside the time loop: one !$omp parallel around the whole loop, an !$omp do` on the interior sweep, and an `!$omp single (with its implicit barrier) for the per-step buffer commit and boundary re-imposition. Saves: one fork/join for the entire run instead of one per step — the fork overhead is amortized over all steps. New hazard: the per-step serial work (the buffer swap, the boundary conditions, any I/O) now lives inside the region and must be guarded by !$omp single (or !$omp master`); leave it in the plain `!$omp do body or unguarded and every thread performs it, a redundant-write race on the field. Also the step-loop index must be private. Fully worked in Case Study 2.

33.16 L2 residual with a + reduction:

s = 0.0_dp
!$omp parallel do default(none) shared(u, u_prev, nx, ny) private(i, j) reduction(+:s)
do j = 2, ny-1
  do i = 2, nx-1
    s = s + (u(i,j) - u_prev(i,j))**2
  end do
end do
!$omp end parallel do
resid = sqrt(s)

Hand check on a field whose difference from the previous step is 3 at one cell, 4 at another, 0 elsewhere: s = 3² + 4² = 9 + 16 = 25, so resid = sqrt(25) = 5.00. Compilable in code/exercise-solutions.f90 (ex_33_16_l2_residual).

33.17 With $p = 0.96$: (a) ceiling $S_{\max} = 1/(1 - 0.96) = 1/0.04 = \mathbf{25\times}$. (b) on 8 threads $S(8) = 1/(0.04 + 0.96/8) = 1/(0.04 + 0.12) = 1/0.16 = \mathbf{6.25\times}$. (c) on 16 threads $S(16) = 1/(0.04 + 0.96/16) = 1/(0.04 + 0.06) = 1/0.10 = \mathbf{10\times}$. Efficiency on 16 threads is $E = S/N = 10/16 = \mathbf{0.625}$ (62.5%) — already more than a third of the machine wasted on this fixed problem, the strong-scaling wall of Chapter 31.

33.19 Eight real(dp) slots occupy $8 \times 8 = \mathbf{64}$ bytes — exactly one 64-byte cache line, so yes, all eight threads' slots share a single line. The result is false sharing: every time any thread updates its slot, the cache-coherence hardware invalidates that line in all other cores' caches and shuttles it back, serializing independent work (correct answer, severe slowdown). Two standard fixes: (1) use a reduction, which gives each thread a genuinely private accumulator on no shared line; or (2) pad each thread's datum onto its own cache line (e.g. store one value per 64-byte block).

33.20 Two requirements, two independent reasons. Inner index on the inner loop (memory): Fortran arrays are column-major (Ch. 5), so u(i,j) and u(i+1,j) are adjacent in memory; running the inner loop over i walks contiguous addresses and uses each cache line fully, whereas putting i on the outer loop strides a whole column per step and wastes most of every cache line. Getting this wrong costs performance (a cache-hostile sweep, several times slower — Ch. 27), not correctness — the answer is still right, just slow. i listed private (threads): i is the inner loop's index, not the !$omp do (outer, j) index, so it is not auto-private; left shared, all threads would read and write one i and clobber each other — a race. Getting this wrong costs correctness (wrong, nondeterministic results). Memory reason → speed; thread reason → correctness; the two are unrelated and both must be right.

33.22 The FTCS two-buffer update makes every interior cell's new value depend only on the old neighbour values: the sweep reads the unchanging old field (field%u) and writes a separate new buffer (u_new). So no cell's update depends on another cell's new value — the interior updates are independent (embarrassingly parallel), and the exact arithmetic each cell performs is fixed regardless of which thread computes it or in what order the threads run and finish. The resulting field is therefore bit-for-bit identical on 1, 8, or 64 threads and identical to the serial Chapter 24 solver; only the schedule is nondeterministic. If the parallel and serial answers did differ, it would signal a scoping bug — a race, such as a shared loop temporary or writing into the field in place instead of a separate buffer — not a legitimately faster answer. (The stencil update is not a reduction, so unlike a floating-point sum it is exactly reproducible, with no last-bit variation.)

33.21 The gap is overhead — the difference between measured speedup and the overhead-free Amdahl ideal. Two causes from this chapter (any two): fork/join overhead paid per parallel region (worst when a fresh team is forked every time step — hoist it); synchronization cost (implicit/explicit barriers, or a critical/atomic serializing an inner loop); and false sharing or memory-bandwidth saturation (the stencil is memory-bound, so several threads sharing one path to memory stop scaling regardless of core count). Amdahl assumes perfect, overhead-free parallelism; the shortfall is real-world overhead.

33.23 Each thread's private copy of the accumulator is initialized to the operator's identity: reduction(+:s)0; reduction(*:p)1; reduction(max:m)−∞ (in practice -huge(m)). (For completeness: min+huge; .and..true.; .or..false..)

33.25 Port of the Python sum-of-squares:

real(dp) :: s
integer  :: i
s = 0.0_dp
!$omp parallel do default(none) private(i) reduction(+:s)
do i = 1, 10
  s = s + real(i, dp)**2
end do
!$omp end parallel do
print '(f0.1)', s

Predicted value: $1 + 4 + 9 + 16 + 25 + 36 + 49 + 64 + 81 + 100 = \mathbf{385.0}$, independent of thread count (correct reduction, exact integer partial sums). Compilable in code/exercise-solutions.f90 (ex_33_25_sum_of_squares). One sentence on why Fortran matters here: inside a ten-thousand-iteration outer loop, the pure-Python version drops into interpreted bytecode ten thousand times and crawls, while the Fortran keeps the entire nest compiled and threaded.

33.27 Threading and SIMD exploit different hardware — several cores versus a single core's vector unit — so their speedups multiply rather than add: !$omp do spreads the iterations over (say) 8 cores, and simd makes each core process 4 elements per vector instruction, for a ceiling near $8 \times 4 = \mathbf{32\times}$ throughput (before memory bandwidth and overhead bite). The stencil/whole-array style (Chapter 5) is especially amenable to simd because the loop body is a uniform, branch-free, dependency-free elementwise expression over unit-stride (column-major) memory — exactly the shape a vector unit wants; nothing in the inner update forces the compiler to play safe. The honest caveat: because the stencil is memory-bound, the realized SIMD gain is often capped by memory bandwidth rather than by the vector width.

33.29 It is neither a race nor a bug — it is floating-point non-associativity (Chapter 20). A parallel reduction adds the elements in a different grouping than the serial left-to-right loop (and a different grouping again for each thread count); since every floating-point addition rounds, $(a+b)+c$ can differ from $a+(b+c)$ in the last bit, and those last-bit differences accumulate to a relative error on the order of machine epsilon (~$10^{-16}$ for double precision) — hence a disagreement in the 14th digit. The tell that it is not a race: a race produces large, first-digit-wrong, unrepeatable errors; this is tiny, bounded (roughly $n\varepsilon$ relative), and of the same small order every run. A regression test for the routine must therefore assert agreement to a tolerance (e.g. relative error $< 10^{-12}$), never bit-for-bit equality across thread counts; if genuine reproducibility is required, use a compensated (Kahan) summation.


Chapter 34 — MPI: Distributed-Memory Parallelism

Solutions to the daggered (†) and odd-numbered problems. The code answers (34.8, 34.18, 34.28) are also worked as compilable, hand-checked programs in code/exercise-solutions.f90. Design problems (34.18, 34.19) give a model answer; reasoned variants are fine.

34.1 In OpenMP the workers are threads sharing one address space, so a value written by one is directly visible to another. In MPI the workers are processes with private memory — no process can read another's variables. To obtain a value another process computed, it must be sent (mpi_send) by the owner and received (mpi_recv) by the process that needs it: coordination is by explicit message, never by shared state.

34.3 With -np 3 you get three lines, Hello from rank 0 of 3, Hello from rank 1 of 3, and Hello from rank 2 of 3. The of 3 is process-count-dependent (it would be of N under -np N, with ranks 0..N-1). What is not guaranteed is the order the three lines appear in: the processes print concurrently to one terminal, so any interleaving is correct — the line order is nondeterministic.

34.5 mpi_send(buf, count, datatype, dest, tag, comm, ierr): buf the data; count how many elements; datatype the element type (e.g. MPI_DOUBLE_PRECISION); dest the destination rank; tag a label on the message; comm the communicator; ierr the status code. mpi_recv differs in two ways: it names a source rank instead of a destination, and it inserts a status argument (an integer(MPI_STATUS_SIZE) array) just before ierr.

34.7 It does not crash. MPI trusts the datatype you declare, so it copies bytes according to MPI_INTEGER (4 bytes each) out of a buffer holding real(dp) (8 bytes each); the receiver gets reinterpreted garbage, silently, with no error. This class of bug is dangerous precisely because there is no diagnostic — the program "works," just wrongly. The rule that prevents it: the datatype must match the buffer's actual typeMPI_DOUBLE_PRECISION for real(dp), MPI_INTEGER for integer, MPI_REAL for default real.

34.9 With -np 8, contributions are rank+1 = 1,2,...,8. mpi_allreduce with MPI_SUM gives $1+2+\dots+8 = 36$; with MPI_MAX it gives $8$. In general for -np N: sum $= N(N+1)/2$, max $= N$ (both process-count-dependent); the broadcast value 100.0 is not.

34.11 A collective is a single cooperative operation across the whole communicator: MPI's algorithm (often a tree) requires every process to participate at its point in the pattern. If mpi_bcast is reached by all processes except the ones that skip the branch, those absent processes never make the matching call, and the participating processes wait for them forever — the collective hangs. The rule violated: every process in the communicator must call the same collective; a collective may never be inside a rank-conditional branch that some ranks skip.

34.13 It is the standard-mode send/recv deadlock: both ranks call mpi_send first, so each blocks inside its send waiting for the other to receive, and neither reaches mpi_recv. It passes a small test because mpi_send may buffer a small message (the eager protocol) and return; it hangs on a large message because the send switches to rendezvous mode and blocks until a matching receive is posted. The deadlock-free rewrite is a single mpi_sendrecv:

call mpi_sendrecv(mine,  n, MPI_DOUBLE_PRECISION, other, 0,  &
                  yours, n, MPI_DOUBLE_PRECISION, other, 0,  MPI_COMM_WORLD, status, ierr)

34.15 The receive posts count = nx - 2, fewer elements than the count = nx sent. This is a count mismatch. It does not deadlock and (usually) does not crash: mpi_recv accepts a message whose length is $\le$ its buffer, so it takes the first nx-2 elements and drops the last two — the two boundary columns of the edge row. The symptom is silently wrong data: the ghost row is missing its end columns, corrupting the stencil near the corners. (A receive smaller than the message is an error in some implementations; the safe rule is to make send and receive counts equal — both nx here.)

34.17 A ghost cell is a stored copy of a neighbouring subdomain's boundary data that a process does not own; the one-cell layer of them around a process's owned block is its halo, and halo exchange is refreshing that layer each step from the neighbours. The payoff: once the halo holds the neighbours' current edge rows, the interior update reads u(i,k-1) and u(i,k+1) uniformly — it neither knows nor cares that some of those rows are ghosts copied from another process — so the exact serial Chapter 24 stencil runs over the owned rows with no modification. All the distributed-memory complexity is quarantined in the exchange routine; the physics kernel is untouched.

34.18 (model — non-blocking halo, in code/exercise-solutions.f90.) Post the two receives first (so incoming data has a landing spot), then the two sends, then wait:

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)
! ... update the DEEP interior (rows 2..nloc-1), which needs no ghosts ...
call mpi_waitall(4, reqs, stats, ierr)
! ... now update the edge rows 1 and nloc, which needed the ghosts ...

Tags follow travel direction (up-going = 0, down-going = 1) so each isend matches the neighbour's irecv. Between the posts and the wait, a real solver updates its deep interior rows (2..nloc-1), which read only owned data. You must not read or write the halo buffers u(:,0)/u(:,nloc+1) or the send rows u(:,1)/u(:,nloc) before mpi_waitall returns — the transfer is in flight and touching them is a race.

34.19 (model — 2D decomposition.) Cut the plate into a grid of rectangular tiles (say $P = p_x \times p_y$). An interior tile now has four neighbours — up, down, left, right — so it exchanges four halos each step (top and bottom rows, left and right columns) instead of two. New complications: (1) the left/right halos are columns, u(1,:) / u(nx,:), which in column-major Fortran are strided, not contiguous — you must pack them into a buffer or use an MPI derived datatype (MPI_TYPE_VECTOR); (2) the tile corners: a diagonal neighbour's corner cell is needed by a nine-point stencil (not the five-point one, so the project can skip it), and even for five-point stencils the order of the four exchanges must be chosen so corner ghosts, if needed, arrive correctly. 2D wins at scale because tiles have a smaller perimeter-to-area ratio than thin strips (see 34.23).

   1D strips (this chapter)      2D tiles (Ch. 38 / at scale)
   +----------------------+      +--------+--------+--------+
   |        rank 0        |      | (0,0)  | (1,0)  | (2,0)  |
   +----------------------+      +--------+--------+--------+
   |        rank 1        |      | (0,1)  | (1,1)  | (2,1)  |   each interior tile
   +----------------------+      +--------+--------+--------+   exchanges 4 halos
   |        rank 2        |      | (0,2)  | (1,2)  | (2,2)  |
   +----------------------+      +--------+--------+--------+
   2 neighbours (up/down)         4 neighbours (N/S/E/W)

34.20 The strip is stored u(nx, 0:nloc+1) — the full width on the first index, the decomposed (row) direction on the second — so that a whole row, u(:,k) (the thing the halo exchanges), is a block of nx contiguous reals in Fortran's column-major memory. That lets each halo message be a plain contiguous buffer handed straight to mpi_sendrecv with count = nx, needing no packing and no derived datatype. If you split the first index instead (owned rows along the first index), a halo row would be u(k,:) — a strided slice (stride = leading dimension) — so you would have to pack it into a contiguous buffer or build an MPI_TYPE_VECTOR before every send. The layout that respects column-major order makes the communication trivial; the other layout makes it work.

34.21 $N \times N$ plate, $P$ horizontal strips. (a) One interior process sends both halos per step, each a row of $N$ reals, so $2N$ reals. (b) It computes its interior: $\approx (N/P)$ rows $\times N$ columns $= N^2/P$ cell updates. (c) The ratio is $$\frac{\text{comm}}{\text{comp}} = \frac{2N}{N^2/P} = \frac{2P}{N}.$$ As $P$ grows with $N$ fixed, the ratio grows linearly in $P$ — communication takes an ever-larger share, because the strips get thinner (more perimeter per unit area).

34.23 $1000 \times 1000$ plate, $P = 64$. (a) 1D strips: each strip is $\approx 15.6 \times 1000$; each process exchanges two row-halos of $1000$ reals $= 2000$ reals per step. (b) 2D $8\times 8$ tiles: each tile is $125 \times 125$; each interior tile exchanges four edge-halos of $125$ reals $= 500$ reals per step. So 2D moves $2000/500 = 4\times$ less halo data per process. General reason: communication scales with a subdomain's perimeter and computation with its area; square-ish tiles have a smaller perimeter-to-area ratio than thin strips, and the gap widens as $P$ grows — which is why production codes use 2D (or 3D) decompositions at large process counts.

34.25 Under OpenMP all threads see the same single copy of the field in one shared address space, so parallelising the update loop needs no data partitioning — a directive divides the iterations and 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 across processes, and any cell a process needs but does not own must be copied in as a ghost and refreshed by message. The one responsible property is shared versus distributed memory: shared memory lets you parallelise in place; distributed memory forces decomposition and halos.

34.27 The distributed solver's inner update is byte-for-byte the Chapter 24 stencil, and keeping it unchanged across the coarray (32), OpenMP (33), and MPI (34) versions is a feature by design: it means the validated physics is written and tested once, and each parallel model only supplies the data movement around it (coindexed access, a directive, or a halo exchange). The design decision from Chapter 24 that made this possible is the two-array, read-old/write-new structure of the FTCS update: because every new value depends only on old neighbour values, the interior update is independent across cells and needs, from outside, only the neighbours' old edge rows — exactly what a halo delivers. Good serial design (no in-place update) paid forward into portable parallelism.

34.28 (port — mpi4py Allreduce(MAX) to Fortran + MPI, in code/exercise-solutions.f90.)

program port_allreduce
  use mpi
  use, intrinsic :: iso_fortran_env, only: dp => real64
  implicit none
  integer  :: ierr, rank
  real(dp) :: mine, gmax
  call mpi_init(ierr)
  call mpi_comm_rank(MPI_COMM_WORLD, rank, ierr)
  mine = real(rank + 1, dp)
  call mpi_allreduce(mine, gmax, 1, MPI_DOUBLE_PRECISION, MPI_MAX, MPI_COMM_WORLD, ierr)
  if (rank == 0) print '(a,f4.1)', 'global max = ', gmax
  call mpi_finalize(ierr)
end program port_allreduce

comm.Allreduce(mine, gmax, op=MPI.MAX) maps directly to mpi_allreduce(mine, gmax, 1, MPI_DOUBLE_PRECISION, MPI_MAX, MPI_COMM_WORLD, ierr); NumPy's dtype='float64' is MPI_DOUBLE_PRECISION (= real(dp)), and the count is 1. Launched with -np 5, contributions are 1,2,3,4,5, so gmax = 5.0 on every rank (all-reduce), and the general result is gmax = nprocs.


Chapter 35 — GPU Computing

Solutions to the daggered (†) and odd-numbered problems (plus the computational 35.26). The computational answers (35.5, 35.22, 35.23, 35.24, 35.26) are also worked as runnable, hand-checked code in code/exercise-solutions.f90. Design problems (35.25) give a model answer; reasoned variants are fine.

35.1 The host is the CPU and its main memory (RAM): it runs the main program, does the serial work and I/O, and issues commands to the accelerator. The device is the GPU and its own separate on-board memory: it executes the parallel kernels the host sends it. The one crucial consequence of separate memory spaces: data must be explicitly copied between host and device before a kernel can use it and after it produces results — nothing the host computes is visible to the device (or vice versa) until it crosses the bus. Managing those copies is the central discipline of GPU programming.

35.3 Offloading is moving a parallel region — a loop or kernel plus the data it needs — from the host to the device to execute, then bringing the results back. You typically do not offload the serial control flow / setup (reading a config file, allocating, imposing initial conditions) or the I/O (writing files, printing); those stay on the host, and only the hot, data-parallel kernels go to the device.

35.5 v = [1, 2, 3, 4, 5, 6], then v(i) = v(i)^2 + 1 gives [2, 5, 10, 17, 26, 37]. Printed with '(6f7.1)':

    2.0    5.0   10.0   17.0   26.0   37.0

A correct offload produces exactly this (the GPU computes the same arithmetic).

35.7 x and y are read-only inputs (copyin); z is a write-only output (copyout) — move no more than necessary:

!$acc parallel loop copyin(x, y) copyout(z)
do i = 1, n
  z(i) = x(i) + 2.0_dp*y(i)
end do

35.9 -Minfo=accel makes nvfortran report what it offloaded: which loops became GPU kernels, how it mapped the parallelism (gangs/vectors), and — most importantly — what data it moved and where. It is the first flag to reach for because a misbehaving offload is almost always a data-movement problem (an unexpected per-step copy), and the feedback shows exactly where copies happen, so you can confirm the field is resident (copied only at the data-region boundary) rather than re-transferred every step.

35.10 The index formula i = blockIdx%x*blockDim%x + threadIdx%x is the CUDA C (zero-based) form. CUDA Fortran's blockIdx%x and threadIdx%x are one-based, so this overshoots by a full block: for block 1 it produces blockDim%x + threadIdx%x (e.g. 257..512 for a 256-thread block), which with n <= 256 sends every thread past the guard and writes nothing; for more blocks it corrupts data at the boundaries. Fix with the one-based index:

i = (blockIdx%x - 1)*blockDim%x + threadIdx%x

35.11 (a) nblocks = ceiling(1000/256) = 4 (use (1000 + 255)/256 = 4 in integer arithmetic). (b) 4 * 256 = 1024 threads launched. (c) 1024 > 1000, so threads with global index i = 1001..1024 have no data; the if (i <= n) guard makes them do nothing instead of writing out of bounds.

35.12 A kernel must be a module procedure because the host needs its interface to issue the <<<>>> launch, and CUDA Fortran requires attributes(global) subroutines to live in a module (they cannot be internal contains procedures of the program). Scalar arguments get the value attribute so they are passed by value from host to device: a host scalar is not in device memory, so passing it by reference (the Fortran default) would hand the device a host address it cannot dereference; value copies the scalar into the kernel's parameter instead.

35.13 Three differences: (1) Amount of code — OpenACC is a single !$acc parallel loop directive on ordinary Fortran; CUDA Fortran is an explicit attributes(global) kernel plus device-array declarations, a launch config, and hand-written index arithmetic. (2) Portability — OpenACC is an open standard compiled by several compilers and can target GPU or CPU cores; CUDA Fortran is NVIDIA-only, nvfortran-only. (3) Who manages the machine — OpenACC lets the compiler choose the thread/block mapping and (with data clauses) the transfers; in CUDA Fortran you choose the launch configuration, declare device arrays, and compute indices yourself. For new code, reach for OpenACC first: less code, portable, and usually fast enough. Drop to CUDA Fortran only for control you can prove (by profiling) you need.

35.14 (a) copy(u) on the per-step kernel is a round trip (in and out) every step, so over 10,000 steps the run performs 2 * 10,000 = 20,000 one-way transfers of u. (b) Hoist the data movement into a region around the whole loop:

!$acc data copy(u) create(u_new)
do step = 1, nsteps
  !$acc parallel loop present(u, u_new)
  ...
end do
!$acc end data

Now the field crosses the bridge exactly twice — one copy in at !$acc data`, one out at `!$acc end data.

35.15 Use !$acc update self(field)` (equivalently `!$acc update host(field)) inside the data region, every 500 steps: it refreshes the host's copy of the field from the device without ending residency, so you can write the VTK frame from the host and then keep computing on the device.

35.16 Arithmetic intensity is flops per byte moved. A single five-point stencil sweep does about 6 flops on each 8-byte cell — an intensity of roughly 0.1–0.4 flops/byte — so it is memory/transfer-bound: the cost of shipping the 32 MB field across the bus dwarfs the tiny arithmetic, and a lone sweep loses on the GPU. A solver that sweeps a resident field 10,000 times amortizes the one-time transfer over 10,000 cheap sweeps: the field crosses the bridge once and is then reused on the device, so the effective work-per-byte-transferred rises ~10,000×, the transfer vanishes into the noise, and the GPU's throughput wins. Reuse converts a transfer-bound kernel into a compute-bound run — which is exactly what a data region provides.

35.17 False. The kernel's speedup is not the program's speedup. Offloading accelerates only the offloaded part; the serial fraction (setup, I/O) is unchanged, and the host–device transfers are added. By Amdahl's Law those un-accelerated costs cap the whole-program speedup, and for a memory-bound kernel with per-step transfers a 20× kernel can even be a net slowdown.

35.18 Cause: the copy(u) clause is on the per-step kernel, so the entire field is shipped to the device and back every step; for a memory-bound stencil (a few flops per cell) those two transfers per step dominate the cheap computation and drag the run below the CPU baseline (§35.4, Case Study 1). Structural fix: wrap the whole time loop in a data region so the field is resident, mark the kernel present, and commit on the device (not with a host array-section assignment, which would bounce u_new/u to the host):

!$acc data copy(u) create(u_new)
do step = 1, nsteps
  !$acc parallel loop collapse(2) present(u, u_new)   ! stencil
  ...
  !$acc parallel loop collapse(2) present(u, u_new)   ! commit, on the device
  ...
end do
!$acc end data

Transfers drop from 2*nsteps to 2. The kernel never needed changing — the structure did.

35.19 The loop a(i) = a(i) + a(i-1) has a loop-carried dependency: iteration i reads a(i-1), which the previous iteration just wrote. The iterations are therefore not independent, but !$acc parallel loop runs them concurrently, so each thread may read a(i-1) before or after its neighbour updates it — a race that gives a wrong, run-dependent result. This is a prefix sum (scan), which needs a dedicated parallel-scan algorithm, not a plain parallel loop. (A plain sum reduction, by contrast, is fine with reduction(+:...).)

35.20 The formula i = blockIdx%x*blockDim%x + threadIdx%x is CUDA C, where blockIdx.x and threadIdx.x are zero-based. In CUDA Fortran they are one-based (to match Fortran arrays), so the formula overshoots by a full block — even with one block it yields blockDim%x + threadIdx%x, leaving the first blockDim%x elements untouched. Correct CUDA Fortran index:

i = (blockIdx%x - 1)*blockDim%x + threadIdx%x

(blockDim%x and gridDim%x are counts, not indices, so they are not shifted — only the two Idx variables are one-based.)

35.21 real(dp) :: a_d(n) declares an ordinary host array — the device attribute is missing — so a_d is in host RAM, not device memory. Passing it where the kernel expects a device array (or launching over it) is a placement error. One-word fix: add device:

real(dp), device :: a_d(n)

Now a_d = a performs the host-to-device copy and the kernel receives genuine device memory.

35.22 (confirmed in code/exercise-solutions.f90.) A $1000\times1000$ real(dp) field is $10^6 \times 8 = 8\times10^6$ bytes. (a) 8 MB. (b) One host→device copy: $8\times10^6 / 16\times10^9 = 5\times10^{-4}$ s $= $ 0.5 ms. (c) Round trip (in + out): 1.0 ms. (d) Per step the GPU does $0.05$ ms of compute plus a $1.0$ ms round trip $= 1.05$ ms, versus the CPU's $1.0$ ms — so the GPU is slower per step, because the per-step transfer swamps the cheap kernel. (Residency fixes this — see 35.23.)

35.23 (confirmed in code.) With residency the field transfers once (round trip $1.0$ ms total) and each GPU sweep costs $0.05$ ms; the CPU sweeps in $1.0$ ms. Crossover: $1.0 + 0.05K < 1.0\,K \Rightarrow 1.0 < 0.95K \Rightarrow K > 1.05$, so **$K = 2$** steps. At $K = 10{,}000$: GPU total $= 1.0 + 0.05\times10{,}000 = 501$ ms; CPU $= 10{,}000$ ms; speedup $= 10{,}000/501 \approx 19.96\times$. One transfer, ten thousand cheap sweeps — the residency pattern turns a per-step loser into a ~20× winner.

35.24 (confirmed in code.) The stencil does ~6 flops per cell (real(dp) = 8 bytes). (a) Counting all 6 memory touches (5 reads + 1 write): $6 / (6\times8) = 6/48 = $ 0.125 flops/byte. (b) Counting only the 2 that must reach main memory in a well-cached sweep (one read miss + one write): $6 / (2\times8) = 6/16 = $ 0.375 flops/byte. Both are low — the kernel does almost no arithmetic per byte it moves — which is the warning sign of a transfer-/memory-bound kernel: it can only win on the GPU through on-device reuse (many resident sweeps), never on a single pass.

35.25 (model — directives only.) Keep the field resident, offload the per-step stencil, and refresh the host only for output:

!$acc data copy(u) create(u_new)                    ! <-- transfer IN, once
do step = 1, nsteps
  !$acc parallel loop collapse(2) present(u, u_new) ! stencil: no transfer
    ...
  !$acc parallel loop collapse(2) present(u, u_new) ! commit: no transfer
    ...
  if (mod(step, 100) == 0) then
    !$acc update self(u)                            ! <-- refresh host copy (frame-sized)
    call write_vtk(u, step)                         ! host I/O
  end if
end do
!$acc end data                                      ! <-- transfer OUT, once

Transfers: one in (!$acc data`), one out (`!$acc end data), plus one update self per 100 steps for output. The stencil update itself never crosses the bridge — the score is the transfer count.

35.26 (confirmed in code.) Fold the one-time transfer into the serial fraction. Offloadable $p = 0.98$, kernel speedup $25\times$, serial $0.02$, transfer $0.01$ (as fractions of the original run time). The offloaded part becomes $p/25 = 0.98/25 = 0.0392$; the un-accelerated part is $0.02 + 0.01 = 0.03$. New time $= 0.03 + 0.0392 = 0.0692$, so speedup $= 1/0.0692 \approx 14.45\times$. The no-transfer ideal is $1/(0.02 + 0.0392) = 1/0.0592 \approx 16.89\times$. The one-time transfer costs about $2.4\times$ of speedup — modest here because it is paid once (residency); a per-step transfer would be catastrophic (35.18).

35.27 Fortran is column-major, so the first index i varies fastest in memory: u(i,j) and u(i+1,j) are adjacent. Mapping that fastest-varying dimension across neighbouring GPU threads gives coalesced memory access — consecutive threads touch consecutive addresses, so the GPU's memory system delivers full bandwidth in wide transactions. It is the exact GPU analogue of the cache-friendly CPU access of Chapter 27: get the mapping backwards and neighbouring threads stride across memory, starving the device just as a bad loop order starves the cache. The column-major awareness that made the serial sweep fast makes the GPU sweep fast.


Chapter 36 — Anatomy of a Real Scientific Code

Solutions to the daggered (†) and odd-numbered problems. Coding solutions (36.10, 36.15, 36.19, 36.21, 36.25) are also compilable in code/exercise-solutions.f90. Navigation problems (36.12, 36.14) have answers that depend on the code you point them at; a model response is given.

36.1 (a) utility (kinds + constants — foundational, used everywhere); (b) physics (one process, the Laplacian, as a pure function); (c) driver (program run orchestrates: read, loop, write — no science itself); (d) I/O (parsing a namelist is talking to the outside world); (e) solver (the time-stepping engine, step/stable_dt).

36.3 The tree orders modules by dependence: util/ holds modules that depend on nothing in the project, driver/ holds the module that depends on (almost) everything, and the middle layers depend only downward. Reading top-down you see what the code does (orchestration resting on computation resting on utilities); reading bottom-up you get the compile order, because each module can be compiled only after everything it uses. Implied compile order: util/ modules → core/physics/io/driver/app/main.

36.5 A new HDF5 writer is an I/O concern, so it goes in src/io/ — e.g. io/hdf5_writer.f90 — as a sibling of field_io.f90. The module that would use it is the driver (driver/run.f90), which chooses the output format and calls the writer; the physics and solver modules never touch it. (If output-format selection is itself factored out, a small io/output.f90 dispatcher would use the new writer and the driver would call the dispatcher.)

36.7 The convention is: each file holds exactly one module, and the module's name matches the file's name (diffusion.f90 holds module diffusion). It makes a huge code navigable from a file listing alone, because "where is the code for module X?" always has the answer "in X.f90," and the use X lines in other files map one-to-one onto files you can open. Break it — three modules in one file, or a module whose name does not match its file — and you lose that guarantee: you must now grep for module <name> to find where a module actually lives, and the directory listing stops being a reliable map.

36.8 (a) The compile order is set by the dependency lines (foo.o : bar.o), which tell make that bar.o (and its .mod) must be built before foo.o; make topologically sorts them. (b) To add boundary.f90 used by heat_solver, you must (i) add boundary.o to the OBJS list, and (ii) add its dependency line boundary.o : kinds.o (whatever it uses) and extend heat_solver's line to heat_solver.o : kinds.o heat_types.o boundary.o. Forget the dependency and make may compile heat_solver before boundary, producing the compile-time error Fatal Error: Cannot open module file 'boundary.mod' — the .mod does not exist yet (Chapter 8, §8.5).

36.9 fpm: compile order is derived automatically from use statements; you maintain nothing by hand — add a file and it is found. Makefile: compile order is spelled out by hand in dependency lines you must keep in sync as modules change. Prefer CMake when the project is large and cross-platform, mixes Fortran with C/C++, must find and configure many external libraries, or needs many build options/generators — territory where fpm's convention is too narrow and a hand-Makefile too fragile.

36.10 Output as written (debug = .true.):

build version : 1.2.0
[debug] grid   : 64 x 64
grid cells    : 4096

After changing debug to .false. and recompiling:

build version : 1.2.0
grid cells    : 4096

The [debug] line disappears because if (debug) becomes if (.false.), a compile-time constant; the optimizer performs dead-code elimination and removes the guarded print entirely, so it costs nothing in the release build (the same removable-check idea as Chapter 13's logical, parameter :: checking).

36.11 A build configuration is the full set of choices — compiler, version, flags, enabled options/macros, and linked library versions — that turn source into a program. Example: building the solver -O3 -ffast-math versus -O0 can change the last digits of the results (reassociation and fused multiply-add alter rounding), and linking a different LAPACK/BLAS can shift an eigenvalue. A reproducible project must therefore record the compiler and version, the exact flag string, and the pinned versions of every dependency (Chapter 37).

36.12 (model) With sources in src/ and app/:

$ grep -rin "^\s*program " app/ src/         # (a) the entry point
$ grep -rin "^\s*module "  src/              # (b) every module defined
$ ctags -R src/ app/                          #     build a tags index, then jump with the editor
$ grep -rin "subroutine step" src/            # (c) where step is DEFINED
$ grep -rin "call step" src/ app/             # (d) every caller of step

Use -i for case-insensitivity (Fortran does not care about case), and prefer tags/fortls over grep for (c)/(d) once names get renamed on import.

36.13 Case-insensitivity: a plain grep "step" may miss an all-caps FORTRAN 77 caller CALL STEP (or match it only if you remember -i); worse, grep -i "step" also matches unrelated names like timestep or footstep, so text hits over- and under-match the symbol. Renaming on import: use m, only: s => step means the code calls the procedure as s, so grepping step misses the call site entirely, and grepping s drowns in noise. A tags index or a language server (fortls) resolves both, because it indexes symbols with scope and aliases, not text — it knows s here is step there, and that timestep is a different symbol.

36.14 (model) A FORTRAN 77 code has no use graph; its "wiring" runs through COMMON blocks and INCLUDE files. Build the equivalent map by grepping for those instead: grep -rin "common */[a-z]" to find each named COMMON block and every routine that declares it (the block name plays the role use plays — it names a shared dependency), and grep -rin "include" to find the shared declaration files pasted across routines. The "module map" becomes a map of which routines touch which COMMON block; a block touched by forty routines is a global hub exactly like a heavily-used module, and just as important to understand first (Chapter 17).

36.15 Output:

global, defaults :   1.00
global, after set:   2.00
explicit argument:   2.00

via_global() returns two different values from identical calls because it reads the module variables alpha and n — hidden inputs — and the line call set_config(0.25_dp, 8) between the two calls mutated that shared state (0.5×2 = 1.00, then 0.25×8 = 2.00). via_arg(0.25, 8) cannot vary this way: its entire input is in its argument list, so for given arguments it returns the same value every time — nothing hidden can change underneath it.

36.17 The field's four moves: born in the driver's setup (program heat / field_init in heat_types allocates and initializes field%u); read and written in heat_solver's step each iteration (the physics reads the old field, computes the Laplacian, writes the interior back); read by heat_io's write_field to produce output; and it leaves the program as a file on disk. "Follow the data" beats "follow the control flow" in numerical code because the computation is the transformation of the field — the loops and conditionals are scaffolding around the field's movement, and they only make sense once you see what data is moving and how it changes.

36.19 See code/exercise-solutions.f90 (module diffusion with laplacian; heat_solver2 whose step2 uses it). The physics is unchanged: on the 5×5 hot-top-edge case, two steps give u(2,3) = 32.00 and maxval = 100.00, identical to Chapter 24. Module-map change: before, heat_solver was a single node holding both engine and operator; after, heat_solver → diffusion (the solver depends downward on a new physics module), and both still rest on heat_types/kinds. The graph stays a tree; a domain scientist can now edit diffusion without opening the solver.

36.21 See code/exercise-solutions.f90 (module constants). It sits in the utility layer, beside kinds, using only kinds for dp. Any module may use constants (it is at the bottom of the graph). Adding it does not force heat_io to recompile: heat_io does not use constants, so its .mod dependencies are unchanged; only a module that actually adds use constants recompiles. Printing pi and two_pi with f10.6 gives 3.141593 and 6.283185.

36.23 Lines read: 150 (driver) + 3×350 (modules) + 400 (solver) = 150 + 1050 + 400 = 1,600 lines of 100,000, i.e. 1.6%. (Case Study 1's tighter search read ~0.05%.) The point of §36.4 stands starkly: you became productive having read one to two percent of the code, because you navigated its architecture and read only what your task required — reading the whole thing was never necessary and never the plan.

36.25 See code/exercise-solutions.f90 (module robust, make_field(u, n, ok)). The rewrite (a) validates the preconditionif (n < 1) then ok = .false.; return — before touching memory, and (b) guards the allocation with allocate(u(n,n), stat=s) and reports ok = (s == 0) instead of aborting. It accepts n = 4 (a 4×4 field, size = 16) and refuses n = -1 cleanly. This is the Chapter 13 pair — validate at the boundary, guard every allocation — applied to a fragile routine.

36.27 Mapping: main.py → the driver program (app/main.f90); solver/engine.py → a solver module (src/heat_solver.f90); physics/diffusion.py → a physics module (src/diffusion.f90); utils/io.py → an I/O (and/or utility) module (src/heat_io.f90). The guarantee the Fortran layering has that the Python layering does not: the module boundaries are enforced by the compiler — a physics module that tried to reach up into the driver simply would not compile — whereas Python's package layering is a convention a linter merely hopes you followed, breakable at runtime with an import.


Chapter 37 — Answers to Selected Exercises

Worked solutions to the daggered (†) and odd-numbered problems. Compilable solutions (37.7, 37.12, 37.22) are in code/exercise-solutions.f90; the reasoning is reproduced here.


37.1 † (a) unit — checks one procedure (laplacian) on a special input. (b) regression — compares the whole output to a stored result from a previous release (guards against change). (c) verification — compares the whole solver to a known-true analytical answer (the steady state). (d) unit — checks one procedure (stable_dt) against an invariant ($r \le 1/4$). (e) regression — reproducing the FORTRAN 77 original's output is the legacy-modernization regression test of Chapter 18.

37.3 † Floating-point arithmetic rounds, so two values that should be equal often differ in the last bit; an == test then fails on correct code. The canonical example:

if (0.1_dp + 0.2_dp == 0.3_dp) print *, 'equal'   ! never prints

0.1_dp + 0.2_dp is 0.30000000000000004..., which is not the nearest double to 0.3; the sum is correct, yet == reports inequality. The right test is abs((0.1_dp + 0.2_dp) - 0.3_dp) <= 1.0e-12_dp, which passes (the difference is $\sim 5.6\times10^{-17}$).

37.5 † (predict, then break.) The unmodified example-01 prints five PASS lines and --- 5 / 5 checks passed (exit code 0). Now drop / dx**2 (return the unscaled neighbour sum): at $h = 0.5$ the "Laplacian" of $x^2+y^2$ becomes the bare second-difference sum $= 1.0$ at each interior point (the true value $4$ times $h^2 = 0.25$), so laplacian(x^2+y^2) == 4 FAILS at $(2,2)$ and $(3,3)$ (got $1.0$, want $4.0$). The linear test still PASSES — the unscaled second difference of a linear field is $0$, and $0$ divided by anything is still $0$, so a missing multiplicative factor is invisible to an oracle whose expected value is $0$. The two stable_dt checks are untouched and PASS. Net: 2 fail, 3 pass, error stop 1. Lesson: to catch a scaling (multiplicative) bug, test against a nonzero expected value; a zero oracle cannot see a missing factor.

37.7 † (compilable — see code/exercise-solutions.f90.) Hot-top plate, one step, then assert the field is unchanged when its columns are reversed:

call step(f, 1.0_dp, 0.2_dp)
call assert_true('left-right symmetric', maxval(abs(f%u - f%u(:,5:1:-1))) < 1.0e-12_dp)   ! PASS

After one step the field is [100,100,100,100,100 / 0,20,20,20,0 / 0.../ 0...], which is identical to its column-reverse, so the deviation is $0$. Symmetry is a good oracle precisely because you need not predict the individual cell values — only that a left–right-symmetric problem yields a left–right-symmetric answer. A sign or index error in the $x$- versus $y$-term of the stencil breaks symmetry immediately and visibly.

37.9 † Three build-configuration changes that break bit-for-bit on correct source: (1) a different compiler or version — reorders and fuses (FMA) arithmetic differently; (2) different flags, especially -Ofast/-ffast-math, which permit the compiler to reassociate non-associative floating-point addition; (3) a different processor count — a parallel reduction(+:...) combines partial sums in an order that depends on the number of threads/ranks (a different BLAS/LAPACK library is a fourth). Each changes the order of non-associative operations, hence the last bits.

37.11 † Bit-for-bit is right under a pinned configuration: same compiler and version, same flags, same library versions, same processor count, same input. A concrete scenario: verifying that a pure refactor — renaming variables, splitting a module, reordering declarations, with no intended change to any arithmetic — produced identical output. Here even a single changed bit means the refactor accidentally altered behaviour, so bit-for-bit is exactly the check you want, and a tolerance would be too weak — it could hide an unintended reordering the refactor slipped in.

37.12 † (compilable — see code/exercise-solutions.f90.) Initialize the exact linear steady state and confirm step does not change it:

do j=1,5; do i=1,5; s%u(i,j) = 25.0_dp*real(j-1,dp); end do; end do   ! ramp 0,25,50,75,100
before = s%u(3,3)
call step(s, 1.0_dp, 0.2_dp)
call assert_true('steady state fixed point', abs(s%u(3,3)-before) < 1.0e-12_dp)   ! PASS, residual = 0

The residual is $0$ because the five-point stencil is exact for linear fields: the second difference of the ramp is $0$ in each direction, so $\nabla^2 u = 0$ at every interior point, so step adds $\alpha\,dt\cdot 0 = 0$ and leaves the field unchanged. The linear steady state is both the analytical solution and a discrete fixed point — an oracle with no tolerance ambiguity.

37.13 The ratio should be $4$. The scheme is second-order in space ($O(h^2)$), so by the order-of-accuracy result of Chapter 22, $E(h)/E(h/2) = 2^p = 2^2 = 4$: halving $h$ quarters an $O(h^2)$ error. (Keep $r = \alpha\,dt/h^2$ fixed as you refine, i.e. $dt \propto h^2$, so the time error — also $O(h^2)$ here — refines in step and does not spoil the ratio.)

37.14 † Sketch: for h in {h0, h0/2, h0/4}: (1) build a grid at spacing h; (2) set the field to the separable mode $\sin(\pi x)\sin(\pi y)$ at the grid points; (3) step to a fixed final time $T$ with dt chosen to hold $r$ constant (so dt scales like $h^2$); (4) form the exact solution $\sin(\pi x)\sin(\pi y)\,e^{-2\alpha\pi^2 T}$ and the error err(h) = maxval(abs(u_num - u_exact)). Then assert each ratio err(h)/err(h/2) lies in [3.5, 4.5]. Do not assert the ratio equals exactly $4$: it is an asymptotic value, so at finite $h$ higher-order terms, the time discretization, and floating-point round-off perturb it; a window around $4$ is the honest, non-brittle check.

37.15 † (a) The on: [push, pull_request] line: every push and every pull request triggers the workflow. (b) matrix: gcc: [11, 12, 13] runs the whole build-and-test job three times in parallel, once each for gfortran 11, 12, and 13. (c) A failing assert_close increments the failure counter, so the test program calls error stop 1 and exits nonzero → fpm test sees the nonzero exit and itself fails → the CI step's command fails → the job turns red → a red required check blocks the pull request from merging. The exit code is the link at every stage.

37.17 † Per job: ~1 min setup + 8 s tests ≈ ~68 s wall-clock. The three matrix jobs run in parallel, so the wall-clock is ~68 s; the machine-minutes are $3 \times 68\text{ s} \approx 3.4$ minutes total. If unit tests ran on a $1000\times1000$ grid taking, say, 3 minutes each, wall-clock jumps to ~4 min and machine-time to ~12 min per push — and a slow suite is one people bypass with "skip CI" until it is effectively off. Keep unit tests on tiny grids: the oracles (exactness, invariants) are exact at any size, so a $5\times5$ grid tests the logic just as thoroughly and a thousand times faster.

37.18 †

!> Advance the field one explicit (forward-Euler / FTCS) time step, in place.
!  Updates interior cells only; the Dirichlet boundary is held fixed.
subroutine step(field, alpha, dt)
  type(field_t), intent(inout) :: field   !! the field, advanced in place
  real(dp),      intent(in)    :: alpha    !! thermal diffusivity
  real(dp),      intent(in)    :: dt       !! time step; keep r = alpha*dt/h^2 <= 1/4

Yes, it still compiles with gfortran -std=f2018 -Wall: !> and !! are ordinary Fortran comments, which the compiler ignores — FORD is the only tool that reads them, so the documentation lives in the source without changing what compiles.

37.19 Record: the exact code (git commit hash), the compiler and version, the flags, the inputs (namelist/config), the library versions (BLAS/LAPACK/MPI), the random seed (if stochastic), and the processor count (if parallel). The git commit hash is the single item that pins the code itself — everything else pins the build and environment.

37.20 † With no random_seed call, the program does not fix the generator's starting state, so each run draws a different sequence and the result cannot be reproduced. Fix (two lines of substance):

call random_seed(size=n); allocate(seed(n)); seed = 20260722; call random_seed(put=seed)

Record the seed integer (here 20260722) with the output. Caveat: the same seed reproduces the same sequence only on the same compiler/runtime — the Fortran standard does not specify which generator random_number uses, so a different compiler may draw different numbers from the identical seed. For cross-compiler reproducible randomness, ship the actual stream or use a generator whose algorithm you control.

37.21 Large binary outputs bloat the repository permanently (git keeps all history), and git cannot diff or merge them meaningfully, so they make every clone slow and the history unusable. Commit instead the recipe to regenerate them — the inputs (namelist/config), the code (commit hash), and the build configuration — which is tiny and is exactly what reproducibility requires. (Large reference data a test truly needs can live in external storage or a data-versioning tool, referenced by checksum.)

37.22 † (compilable — see code/exercise-solutions.f90.) Add a whole-array assertion:

subroutine assert_all_close(name, got, want, tol)
  character(*), intent(in) :: name
  real(dp),     intent(in) :: got(:,:), want(:,:), tol
  if (maxval(abs(got - want)) <= tol) then; print '(a,a)','PASS  ',name
  else; n_fail = n_fail + 1; print '(a,a)','FAIL  ',name; end if
end subroutine

Then call assert_all_close('golden', f%u, golden, 1.0e-9_dp) tests the full 5×5 two-step field in one call (maxdev $= 0$, PASS). maxval(abs(...)) reduces the whole array to a single worst-case deviation — the natural array generalization of assert_close.

37.23 A pure function has no side effects and no hidden state: its result depends only on its arguments. So a unit test can call it with known inputs and check the output in complete isolation — no files to set up, no global state to initialize, no order-dependence between tests. This is exactly why the five architectural roles (Chapter 36) keep the physics pure and argument-driven and keep file and global-state contact in the I/O and driver roles: the separation is what makes the physics trivially testable.

37.24 † $0.2_{dp}$ stores as $\approx 0.2000000000000000111$. Then $0.2 \times 100 = 20.0000000000000011$; the gap between representable doubles near $20$ is $2^{-48}\approx3.6\times10^{-15}$, and the excess $1.1\times10^{-16}$ is far under half that gap, so the product rounds to exactly $20$. Likewise $0.2 \times 60 = 12.00000000000000067$ rounds to exactly $12$ (gap near $12$ is $2^{-49}$; the excess is under half). So $28$, $32$, $4$ come out bit-exact. Yet you still compare with a tolerance, because this is an accident of these particular values — change dt, the boundary value, or the grid and the products will not land exactly — and because a portable test must survive compiler/flag changes that reorder operations. Relying on exact rounding is fragile; a tolerance is not.

37.25 (a) Hand-rolled harness: call assert_close('x', got, want, 1.0e-9_dp) for scalars, or call assert_all_close('x', got, want, 1.0e-9_dp) for arrays — atol maps to the tol argument. (b) pFUnit: @assertEqual(want, got, tolerance=1.0e-9_dp)atol maps to the tolerance= keyword. In both, the role of NumPy's atol is played by the explicit absolute tolerance.

37.26 † The golden-field test is Chapter 18's modernization regression test generalized: capture the trusted output, then insist every later version reproduces it — pin a known-good result and catch unintended change. Bit-for-bit is tempting in modernization ("the modern code should compute the identical thing") but is often wrong, because merely reordering an expression, changing a literal's kind, or letting the optimizer reassociate under a new flag changes the last bits even when the algorithm is unchanged. So the right target is usually numerical equivalence within a tolerance ("close enough"), not bit-identity — exactly the distinction Chapter 18 drew and §37.2 sharpened.


Chapter 38 — Capstone: From Physics to Publication

Full solutions to the daggered (†) and odd-numbered problems. The compilable ones (38.9, 38.13, 38.17, 38.21, 38.25) are also worked as runnable code in code/exercise-solutions.f90; the values below are hand-computed. Design/write-up problems give a model answer.

38.1 † The property is a stable (frozen) interface: step's signature step(field, alpha, dt) never changed, only its body. Because every caller (the driver, the tests) depended on the signature, not the implementation, the body could be rewritten from serial to OpenMP to MPI with no change anywhere else. This lets a program grow past what one person can hold in their head because you only ever need to understand one interface at a time: to use step you need its signature and its contract, never its (possibly thousand-line, possibly parallel) body. A system of frozen interfaces is a system you can reason about locally.

38.3 † The capstone requires: (1) verification — proving the code is correct (the convergence study); (2) performance analysis — measuring how it scales, honestly, with a stated baseline; (3) presentation — writing it up so someone else can understand and reproduce it. A reviewer reads (1), verification, first: a result whose correctness is not established is not yet a result, no matter how fast or well-presented.

38.5 † Verification = "are we solving the equations right?" — does the code correctly and accurately solve the chosen mathematical model (free of bugs, converging at the right order)? Validation = "are we solving the right equations?" — does the model itself describe physical reality? Comparing the solver to an exact solution of the same heat equation is verification. The other one, validation, would require experimental data — a real heated plate measured in a lab — to test whether the heat equation with our chosen $\alpha$ actually describes that plate. This chapter has no such data and so does verification only, and says so.

38.7 † Observed order between successive grids is $p = \log_2(e_{\text{coarse}}/e_{\text{fine}})$: - $h \to h/2$: $\log_2(1.60\times10^{-2} / 4.05\times10^{-3}) = \log_2(3.95) = 1.98$. - $h/2 \to h/4$: $\log_2(4.05\times10^{-3} / 1.01\times10^{-3}) = \log_2(4.01) = 2.00$. Both are $\approx 2$, so the solver behaves exactly as the five-point stencil's $O(h^2)$ theory predicts. The single number to report in the paper is the observed order of accuracy, $\approx 2$ (or, more fully, the convergence table with the ratios) — it is the most diagnostic statement of correctness available.

38.9 † (Compilable — example-01-analytical-validation.f90.) On the $5\times5$ grid ($h = 1/4$), the center starts at $\sin(\pi/2)^2 = 1$. One step multiplies it by $G = 1 - 8r\sin^2(\pi h/2) = 1 - 1.6\sin^2(\pi/8) = 1 - 1.6(0.146447) = 0.765685$, so **numerical $= 0.765685$. The exact value after $\Delta t = 0.0125$ is $e^{-2\pi^2(0.0125)} = e^{-0.246740} = 0.781344$. $|\text{error}| = 0.015658$. The error is large because $h = 1/4$ is coarse (only three interior points across the plate). The one change that shrinks it by ~4 is halving $h$** (refine to $9\times9$): since the error is $O(h^2)$, halving $h$ quarters it. Expected output is in the file's ! Expected output: block.

38.11 Strong scaling: the total problem is fixed and you add processors, hoping each core finishes its share faster; it is bounded by Amdahl (and then by bandwidth). Weak scaling: the problem grows with the processor count so each core keeps constant local work. The OpenMP solver on one node naturally demonstrates strong scaling (one fixed grid, more threads); the MPI solver across a cluster naturally demonstrates weak scaling (give each rank its own strip; grow the plate with the ranks). Weak scaling is the honest way to sell a distributed solver because the point of a thousand nodes is not to solve today's problem a thousand times faster (Amdahl forbids it) but to solve a problem a thousand times larger in the same time — which weak scaling measures directly.

38.13 † (Compilable — example-02-convergence-factors.f90.) The discrete eigenvalue $\lambda_h = -(8/h^2)\sin^2(\pi h/2)$ approaches the continuous $-2\pi^2 = -19.739209$. Errors: $h=1/2$: $3.739$; $1/4$: $0.994$; $1/8$: $0.252$; $1/16$: $0.0633$. The ratio column is $3.762, 3.939, 3.985$, approaching 4, so the observed order is $\log_2(4) = 2$ — second-order accuracy. The coarsest grid's ratio overshoots because $h = 1/2$ is outside the asymptotic regime (the $O(h^4)$ and higher truncation terms are not yet negligible); the ratio settles on 4 as $h$ shrinks.

38.15 † In order: Title/Abstract (what did you do, what did you find), Introduction (why it matters), Governing equations & method (exactly what you solved and how), Verification & validation (how you know it is right), Implementation & performance (how it is built, does it scale), Results (what you found), Conclusion (what it means, what is next), Reproducibility (could someone regenerate it). Verification is placed before results because the reader must be convinced the code is correct before any result carries meaning — a beautiful result from an unverified code is worthless.

38.17 † (Compilable — soln_17 in exercise-solutions.f90.) With $\alpha = 1$, $h = 0.1$: the max stable timestep is $\Delta t_{\max} = h^2/(4\alpha) = 0.01/4 = 0.002500$. For the candidates: $\Delta t = 0.004 \Rightarrow r = \alpha\Delta t/h^2 = 0.004/0.01 = 0.400 > 0.25$ — UNSTABLE (over the CFL cliff by a factor $0.4/0.25 = 1.6$); $\Delta t = 0.002 \Rightarrow r = 0.200 \le 0.25$ — STABLE. The $0.004$ run crosses the cliff and would blow up.

38.19 † (Design.) The test initializes the analytical mode, takes one step, and asserts the center:

program test_solver
  use kinds,       only: dp
  use heat_types,  only: field_t
  use heat_solver, only: step, stable_dt
  implicit none
  real(dp), parameter :: pi = 3.141592653589793_dp
  real(dp), parameter :: expected = 0.765685_dp, tol = 1.0e-4_dp
  type(field_t) :: f
  real(dp) :: x, y, dt
  integer :: i, j
  call f%init(5, 5, 0.25_dp, 0.25_dp)
  do j = 1, 5; do i = 1, 5
    x = real(i-1,dp)*0.25_dp;  y = real(j-1,dp)*0.25_dp
    f%u(i,j) = sin(pi*x)*sin(pi*y)
  end do; end do
  dt = stable_dt(1.0_dp, f%dx, f%dy, safety=0.8_dp)     ! = 0.0125 -> r = 0.2
  call step(f, 1.0_dp, dt)
  if (abs(f%u(3,3) - expected) < tol) then
    print '(a)', 'PASS: center matches analytical one-step value 0.7657'
  else
    print '(a, f9.6)', 'FAIL: center = ', f%u(3,3)
  end if
end program test_solver

Asserting against the analytical value is stronger than against a saved number because a saved "golden" value only proves the code still does what it did last week — even if what it did was wrong. The analytical value is independently known to be correct, so the test proves the code matches the mathematics, catching a bug that was present from the start.

38.21 † (Compilable — soln_21 in exercise-solutions.f90.) A $1000\times1000$ grid has $(1000-2)^2 = 998^2 = 996{,}004$ interior cells. At ~10 flops/cell the update is $\approx 9{,}960{,}040$ flops/step. Marching $10{,}000$ steps is $\approx 9.96\times10^{10} \approx 10^{11}$ flops total. That number alone does not tell you the runtime because the kernel is memory-bound (§38.4): its speed is set by how fast the machine can move the grid to and from memory, not by how fast it can do arithmetic, so flops/second is not the binding rate. You must measure, or use the memory bandwidth and the bytes moved, to estimate time.

38.23 † Refining $500\times500 \to 1000\times1000$ (halving $h$) at fixed final time: the grid has as many cells (2× in each direction), so each timestep costs 4× the work. And the CFL limit forces $\Delta t \to \Delta t/4$ (since $\Delta t \sim h^2$), so you need as many timesteps to reach the same final time. Total work grows by $4 \times 4 = \mathbf{16\times}$. This is the "$\Delta t \sim h^2$ tax": doubling resolution multiplies explicit-diffusion cost by 16, not 4. An implicit scheme (unconditionally stable, so $\Delta t$ need not shrink with $h$) escapes the extra factor of 4 — at the cost of a linear solve per step (Chapter 21).

38.25 † (Compilable — soln_25 in exercise-solutions.f90.) At $(2,3)$ on the $h=1/4$ mode, the initial value is $\sin(\pi/4)\sin(\pi/2) = 0.707107$. Direct FTCS: with $r = 0.2$ and neighbours $u_{1,3}=0$, $u_{3,3}=1$, $u_{2,2}=0.5$, $u_{2,4}=0.5$, $0.707107 + 0.2(0 - 1.414214 + 1) + 0.2(0.5 - 1.414214 + 0.5) = 0.707107 + 0.2(-0.414214) + 0.2(-0.414214) = 0.541421$. And $G \times \text{initial} = 0.765685 \times 0.707107 = 0.541421$ — they match. This eigenvector property means the stencil returns the same grid pattern scaled by $G$, so one step multiplies the entire field by $G$; hence after $K$ steps the field is exactly $G^K u^0$ and the whole-field verification collapses to the single number $|G^K - e^{-2\alpha\pi^2 T}|$. $G$ is exactly the amplification factor of Chapter 24's von Neumann analysis, here evaluated for the smooth fundamental mode rather than the checkerboard.

38.27 † Verification uses the analytical solution once, across many grids, to establish the order of accuracy — a study you run to earn trust. Regression testing uses it repeatedly, on one fixed grid, to assert on every commit that the code still reproduces a known-correct number to tolerance — a guard you keep to retain trust. Recording the compiler, flags, grid, and step count is part of reproducibility for both: the same source built with different flags (Chapter 30, e.g. -Ofast reordering floating-point) can produce slightly different numbers, so a verification result or a regression tolerance is only meaningful relative to a recorded build — otherwise "it passed on my machine" cannot be reproduced or trusted.

38.12 † Arithmetic intensity = floating-point operations per byte of memory traffic. The stencil update does ~10 flops/cell (a few adds and multiplies) and moves on the order of tens of bytes/cell (reading neighbours, writing the result, even with good cache reuse), giving an intensity well under 1 flop/byte. On the roofline plot that places it under the sloped memory-bandwidth ceiling, not the flat peak-compute ceiling — it is memory-bound. The optimization that helps is therefore better memory access (loop order for column-major, cache blocking, contiguous data — Chapter 29), not more arithmetic units or heavier vectorization; you are starved for data, not for flops.


Chapter 39 — Fortran 2023 and Beyond

Solutions to the daggered (†) and odd-numbered problems. Research/reflection items (39.27, and 39.28's open-ended proposal) give a model answer rather than a single right one. Compilable solutions for 39.5, 39.9, 39.19, and 39.21 are in code/exercise-solutions.f90.

39.1 A conditional expression ( cond ? a : b ) evaluates only the selected branch, whereas merge(a, b, cond) is a function and evaluates both a and b. It matters for correctness whenever the unselected value would be invalid or trap — e.g. ( d /= 0 ? x/d : 0.0 ) never divides by zero, but merge(x/d, 0.0, d /= 0) computes x/d even when d == 0.

39.3 The value protected is backward compatibility. Rather than delete an aged feature (which would break validated legacy code), the committee marks it obsolescent — a warning that it is discouraged and may someday go — and almost never actually removes it. A still-standard Part IV feature kept alive this way: fixed-form source (or COMMON blocks, or arithmetic IF) from Chapter 17.

39.5 (compilable — code/exercise-solutions.f90, solve_05) Output:

39.5  degree-trig table (sind equivalents):
  sind( 0) =  0.00000
  sind(30) =  0.50000
  sind(45) =  0.70711
  sind(60) =  0.86603
  sind(90) =  1.00000

sin(0)=0; sin(30°)=0.5; sin(45°)=√2/2≈0.70711; sin(60°)=√3/2≈0.86603; sin(90°)=1. The f8.5 field right-justifies each 7-character value in width 8 (one leading space).

39.7 For nx = 5, t_hot = 100, the angles are 0°, 45°, 90°, 135°, 180°, so the edge is [0.000, 70.711, 100.000, 70.711, 0.000]. The interior values at i = 2 and i = 4 are 100·sind(45°) = 100·0.70710678 = 70.711; the centre (i = 3) is 100·sind(90°) = 100.000; the two corners (i = 1, 5) are 0. Printed (f8.3):

hot-edge temperature profile (half-sine):
  u(1) =    0.000
  u(2) =   70.711
  u(3) =  100.000
  u(4) =   70.711
  u(5) =    0.000

39.9 (compilable — solve_09) Portable Fortran (an explicit if/else short-circuits exactly like the 2023 conditional expression would):

if (x > 0) then
   y = a
else
   y = -a
end if
! Fortran 2023 one-liner (needs a recent compiler):  y = ( x > 0 ? a : -a )

For a = 5, x = -2 gives y = -5.00; x = 3 gives y = 5.00.

39.11 Use the degree intrinsic to remove the pi/180:

real(dp) :: heading = 30.0_dp          ! degrees
real(dp) :: north_component
north_component = cosd(heading)        ! Fortran 2023; == 0.8660254...
! portable fallback:
! north_component = cos(heading * pi / 180.0_dp)

cosd(30°) = cos(30°) = √3/2 ≈ 0.8660254. The degree form has no 180 to mistype and can be exact at nice angles.

39.13 Nothing is wrong with Fortran 2023; the compiler simply has not implemented conditional expressions yet (a standard is a document, and support lags publication — §39.2). Two honest ways forward: (1) write the portable form, an explicit if/else (or merge when both branches are safe), and keep the ? : version in a comment; or (2) upgrade to / test with a compiler that supports the feature (a recent gfortran, or try it in LFortran / the Playground) — but do not commit the ? : form to shared code until every target compiler accepts it.

39.15 With red = 1 and mon = 1, the comparison red == mon compiles and evaluates to .true., even though comparing a colour to a weekday is nonsense — because both are just the integer 1. A real Fortran 2023 enumeration type makes red and mon values of different types, so red == mon becomes a compile-time error: the type system rejects the meaningless comparison. That is exactly the safety the bare-integer idiom cannot provide.

39.17 Portable modernization with named constants:

integer, parameter :: bc_dirichlet = 1, bc_neumann = 2
select case (bc)
case (bc_dirichlet)
   u(1) = t_fixed
case (bc_neumann)
   u(1) = u(2)          ! zero-flux
end select

A Fortran 2023 enumeration type would improve it further by making bc a distinct, compiler-checked type whose only legal values are the boundary enumerators — so a stray bc = 7 (or a value from an unrelated enumeration) would fail to compile, and the case default/unknown path would become unreachable.

39.19 (compilable — solve_19 / bc_name)

function bc_name(code) result(label)
  integer, intent(in) :: code
  character(len=:), allocatable :: label
  integer, parameter :: bc_dirichlet = 1, bc_neumann = 2, bc_periodic = 3
  select case (code)
  case (bc_dirichlet); label = 'Dirichlet'
  case (bc_neumann);   label = 'Neumann'
  case (bc_periodic);  label = 'periodic'
  case default;        label = 'unknown'
  end select
end function

Over [1, 2, 3, 9]: Dirichlet, Neumann, periodic, unknown.

39.21 (compilable — solve_21) For nx = 9, angles are 0°, 22.5°, 45°, 67.5°, 90°, 112.5°, 135°, 157.5°, 180°, giving (× 100):

  u(1) =    0.000
  u(2) =   38.268     ! 100·sind(22.5°)
  u(3) =   70.711     ! 100·sind(45°)
  u(4) =   92.388     ! 100·sind(67.5°)
  u(5) =  100.000     ! 100·sind(90°)
  u(6) =   92.388
  u(7) =   70.711
  u(8) =   38.268
  u(9) =    0.000

Symmetric about the centre (i = 5), peaking at 100 — the smooth localized-source profile we wanted.

39.23 The run-time cost is dynamic dispatch: a class (polymorphic) variable's actual procedure is resolved at run time, so the compiler cannot inline or vectorize through the call — fatal in a tight per-cell loop, which is why the book says keep class out of the hot loop (Chapter 10). Generics would give flexibility over types at compile time: the compiler generates a specialized, type-checked version of the algorithm for each concrete type, with no dispatch and full optimization. You get "works for any type" without the run-time cost — the complement to OOP's run-time polymorphism.

39.25 The dependency line (an inline table with a git key — Chapter 16):

[dependencies]
stdlib = { git = "https://github.com/fortran-lang/stdlib" }

(optionally pinned, tag = "v0.7.0", for reproducibility). A bare gfortran file.f90 command fails on use stdlib_stats because that command knows nothing about stdlib: the stdlib_stats module's .mod file and object code have not been built or linked. fpm build, reading the dependency, fetches, builds, and links stdlib first, after which the use resolves — the same module-availability rule from Chapter 8, now across a package boundary.

39.27 (model answer) On the gfortran I have (record your version and the date), the "Fortran 2023 status" release notes indicate support for, e.g., the degree-valued trig intrinsics, some relaxed source limits, and (in newer releases) parts of the do concurrent locality set — while conditional expressions, enumeration types, and typeof/classof are not yet available (or only experimentally). Write the three supported and two unsupported features down with the version and date, so you can revisit the list as the compiler improves — the point of the exercise is the habit of checking support empirically rather than assuming it from the standard.


Chapter 40 — The Fortran Career

Solutions to the daggered (†) and odd-numbered problems. Reflective, research, and résumé problems have model answers (there is no single key); the three coding problems (40.5, 40.17, 40.18) have complete compilable solutions in code/exercise-solutions.f90.

40.1 † Model. Retrieve the sealed Exercise 1.28 sentence and today's answer, then compare. A strong comparison names something the first answer could only assert on faith and that the reader can now justify concretely — e.g., "I wrote 'Fortran is fast'; I can now say why: first-class arrays and the no-aliasing rule let the compiler vectorize and reorder (Ch 27), and I measured a loop-order speedup myself (Ch 27, 28)." The point is the gap — that it is larger and more specific than expected.

40.3 † Model. Any sector from §40.1 with a concrete computation named. Example: "National labs — I want to work on climate or fusion simulation, marching PDEs on big grids across thousands of cores, because that is the largest-scale version of exactly the finite-difference solver I built."

40.4 † Output of example-01: a header line, eight [x] skill lines, then you can do 8 of 8 and readiness: 100.0%. The percentage is 100.0 because all eight mastered flags are .true., so count(skills%mastered) = 8 and 100·8/8 = 100.0. (Full expected block is in the file's footer comment.)

40.5 † With exactly 6 of 8 flags .true., count = 6 and readiness = 100·6/8 = 75.0, printed as 75.0%. Compilable solution: code/exercise-solutions.f90, solve_40_5 (marks MPI/OpenMP and f2py as still-practicing). The lesson: the percentage is honest self-assessment, and the arithmetic is just count.

40.7 † Model README skeleton: one-line description → Physics (the heat equation $\frac{\partial u}{\partial t}=\alpha\nabla^2 u$, Dirichlet edges) → Build & run (fpm build && fpm run) → Output (field text / VTK per step) → Validation (reproduces the analytical steady state; stencil converges at 2nd order, Ch 22/38). The Validation section is the mark of scientific software — a README without it reads as a toy.

40.9 † Model. The abstract must be true of the reader's code. Grading rule: every clause maps to a feature actually present (modules? parallelism? validation? VTK?). If a clause is aspirational, it is cut or made true. A correct answer is a paragraph in which the reader could point to the file behind each clause.

40.11 † Model. Name a real open code (e.g., a fortran-lang project or a domain code), its domain, and a top-level tree; map two directories to Ch 36 roles (src/ = library, test/ = correctness enforcement, doc/ = prose, app//example/ = entry points). Credit any answer that classifies directories by role rather than by guessing at file contents.

40.13 † Model. A newcomer task = small, self-contained, testable: a doc typo, a missing test pinning existing behavior, a clearer error message, a small profiled optimization. Correct answers name a specific such task and the chapter whose skill it uses (docs → Ch 37; a test → Ch 37; a hot-loop fix → Ch 28/29).

40.14 † Model. One skills line + one project line, adapted from §40.4's templates, with every claim true of the reader's work. Grading: specific methods/libraries named (LAPACK, f2py, OpenMP), no unmeasured benchmark asserted, Fortran framed as "modern (2018)" and paired with Python/HPC.

40.15 Model. "Maintained Fortran code" → e.g., "Modernized and optimized a validated Fortran simulation: added modules and intent, replaced GOTO-based control flow with structured constructs, parallelized the hot loop with OpenMP, and added regression tests guaranteeing bit-for-bit-equivalent results." Every clause is a real skill (Parts IV/VII/IX); nothing untrue is added.

40.16 † Model. "Fortran + Python + HPC" is stronger because it matches how the work is actually done — hot kernel in fast compiled Fortran, orchestration/analysis in Python — and signals the rare judgment of knowing where the boundary is. Alone, "Fortran" reads as legacy maintenance. The bridge is f2py (Ch 15), which wraps a Fortran routine as an importable Python extension module.

40.17 † One $N\times N$ real(dp) field for $N=1000$: $1000^2 \times 8 = 8{,}000{,}000$ bytes; $8{,}000{,}000/1{,}048{,}576 \approx 7.63$ MiB. Budget ~2× because a solver holds at least two fields at once (the current temperature and the Laplacian). Compilable solution: solve_40_17.

40.18 Parameterized abstract via internal-file write; for a $512\times256$ grid, $10000$ steps → "...a 2D solver on a 512x256 grid, 10000 steps.". Compilable solution: solve_40_18. (Not daggered, but listed as a coding problem with a solution in the code file.)

40.19 Model. Three concrete extensions, each tied to a chapter: Neumann/periodic BCs (Ch 24), GPU offload with OpenACC (Ch 35), HDF5/NetCDF output (Ch 25), an implicit step via LAPACK dgesv (Ch 21), or a pFUnit test suite expansion (Ch 37). Credit any three that are specific and chapter-anchored.

40.20 † 5/2 with default integers is 2 — integer division truncates toward zero (Ch 3). Making one operand real fixes it: 5.0_dp/2 (or real(5,dp)/2) gives 2.5.

40.21 The inner loop should run over the first index. Fortran is column-major, so elements with consecutive first index are adjacent in memory; striding the first index in the inner loop gives unit-stride, cache-friendly access. The wrong order strides across memory and can be several-to-~10× slower (Ch 5, 27).

40.22 † The module replaced the COMMON block (Ch 8; legacy contrast Ch 17). Two advantages (any two): it is typed and compiler-checked (not an untyped memory overlay); it provides explicit interfaces for its procedures for free; it supports public/private access control; it can be reasoned about in isolation.

40.23 The CFL condition constrains the time step $\Delta t$: for explicit stepping it must be small enough relative to $\alpha$ and the grid spacing. Violate it and the scheme is unstable — the solution grows without bound and blows up (values explode to Inf/NaN) (Ch 24).

40.24 † f2py wraps a Fortran routine as an importable Python module. Pair them because the hot numerical kernel belongs in fast compiled Fortran while orchestration, analysis, and plotting belong in productive Python — "better together" (Ch 15, theme 6).

40.25 (1) Arrays are first-class objects — the compiler knows an array's shape/layout, so whole-array operations can be vectorized and parallelized. (2) Procedure arguments are assumed not to alias — the compiler may assume array arguments do not overlap, freeing it to reorder loads/stores for speed (Ch 27).

40.26 † dgesv solves $A\mathbf{x}=\mathbf{b}$ for a general dense matrix in double precision. Decode the name: d = double precision, ge = general matrix, sv = solve (the simple driver) (Ch 21).

40.27 † Model. A one-paragraph arc naming one key contribution per Part. A strong answer resembles: Part I gave the solver its bones — the temperature field as a 2-D array and the step procedure with intent; Part II organized it — the field_t derived type and clean modules; Part III connected it outward — the f2py Python driver and the fpm project; Part IV taught the discipline to modernize the old codes it resembles; Part V gave it its physics — the finite-difference core, CFL-stable stepping, and deliberate precision; Part VI let it speak — VTK/NetCDF output; Part VII made it fast — profiling and the optimized stencil; Part VIII made it scale — OpenMP/coarray/MPI parallelism; and Part IX made it software — real architecture, tests, docs, CI, and the capstone write-up. Credit any paragraph that attributes a specific, correct contribution to each part and reads as a single arc.

40.29 † Amdahl's Law: if a fraction $s$ of a program's runtime is inherently serial, the speedup on $p$ processors is at most $1/(s + (1-s)/p)$, which is bounded above by $1/s$ as $p \to \infty$ (Ch 31). So a $5\%$ serial fraction caps speedup at $20\times$ no matter how many processors you throw at it. This is why both moves matter: parallelizing the hot loop lets more processors do useful work (raising the effective $p$), while reducing the serial fraction raises the ceiling itself ($1/s$) — the first gets you toward the limit, the second lifts the limit.