Case Study 2: Designing a Portable, Reproducible Build

"Works on my machine is not a result. It is a confession."

Executive Summary

Where the first case study repaired a broken build, this one asks you to design a good one — from the first day of a project that will grow for thirty-six more chapters. You are going to set up the heat solver so that it builds identically for you on Linux, for a collaborator on macOS, and on the Linux cluster where it will eventually run, in two deliberate profiles (a safe development build and a fast release build), with the exact recipe recorded so that any of you can reproduce any result. None of this requires code you have not already seen — just the program heat skeleton and the flags of §2.4 — but assembling them into a disciplined, portable workflow is a genuine act of engineering design, and it is the difference between a project that stays reproducible to the capstone and one that becomes a pile of forgotten commands. This is where good scientific software habits are cheapest to acquire: at the very beginning, when the program is four lines long.

Skills applied: the two build profiles and every flag behind them (§2.4); the compile–link–run cycle as the thing a build script automates (§2.3); portability of a standard-conforming program across platforms (§2.1, §2.5); recording the compiler and flags for reproducibility (§2.1); verifying output by hand (§2.7).

Background

The program heat skeleton from this chapter's Project Checkpoint compiles and prints a banner. That is the seed. Over the coming chapters it will sprout modules, a numerical core, file I/O, and parallel variants — by Chapter 8 it is several files, and by Chapter 38 it is a full simulation. If you improvise the build each time — retyping a slightly different gfortran command, half-remembering which flags you used for that timing you reported last week — you will lose reproducibility long before the capstone. So we design the build now, while it costs nothing, and grow it deliberately.

Three requirements define a good build, and they are worth stating explicitly because they will guide every decision:

  • Portable. The same project must build on your Linux box, your collaborator's Mac, and the cluster, without editing source. Standard-conforming Fortran (§2.5) plus a portable compiler (gfortran, §2.1) gives us this almost for free — if we are disciplined about flags.
  • Two profiles. A development build optimized for catching mistakes, and a release build optimized for speed. You switch between them constantly, so switching must be trivial and unambiguous.
  • Reproducible. Given the project and the recorded recipe, anyone can regenerate the exact executable and the exact result, months later, on another machine.

Phase 1 — The Directory and the Driver

Start from the folder you created in Chapter 1. Put the driver in it:

heat-solver/
├── README.md        # the plain-language problem statement (Chapter 1)
├── heat.f90         # the driver — the program itself (this chapter)
└── build.sh         # the build recipe (this case study)

The driver is the checkpoint skeleton, unchanged:

program heat
  implicit none

  print '(a)', '=================================================='
  print '(a)', '  heat-solver : a 2D heat-equation simulation'
  print '(a)', '  Chapter 2 build, the skeleton compiles.'
  print '(a)', '=================================================='
  print '(a)', 'Nothing to solve yet. Come back next chapter.'
end program heat

There is no arithmetic here on purpose: a driver that only prints a banner lets us test the build system in isolation, before any numerics exist to confuse a build failure with a computation failure. Design the scaffolding while the thing it scaffolds is trivial.

Phase 2 — Two Profiles as a Deliberate Design Choice

The heart of the design is the pair of build profiles from §2.4. They are not two arbitrary command lines; they encode a genuine tradeoff, and naming that tradeoff is the design.

Development profile Release profile
Flags -std=f2018 -Wall -g -fcheck=all -std=f2018 -Wall -O2
Optimizes for catching mistakes fast running fast
Run-time checks on (-fcheck=all) — bounds, etc. off — every cycle counts
Debug info yes (-g) omitted
When to use while writing and testing for timed and production runs
Never quote a timing from this build run this on code you have not tested

Both profiles share -std=f2018 -Wall, because standard-conformance and warnings are non-negotiable in any build — you never want to silently depend on a compiler extension, and you never want to ignore the compiler's free code review. What differs is the tradeoff below that shared floor: the development profile spends speed to buy safety and diagnostics; the release profile spends safety to buy speed. Build the skeleton both ways and confirm each produces a working heat:

$ gfortran -std=f2018 -Wall -g -fcheck=all heat.f90 -o heat
$ ./heat
==================================================
  heat-solver : a 2D heat-equation simulation
  Chapter 2 build, the skeleton compiles.
==================================================
Nothing to solve yet. Come back next chapter.
$ gfortran -std=f2018 -Wall -O2 heat.f90 -o heat
$ ./heat
==================================================
  heat-solver : a 2D heat-equation simulation
  Chapter 2 build, the skeleton compiles.
==================================================
Nothing to solve yet. Come back next chapter.

The two banners are identical, and that is exactly the sanity check we want: a program with no arithmetic must print the same thing regardless of optimization level. Later, when the solver does real floating-point work, this cross-profile comparison becomes a genuine test — the development and release builds should agree to within floating-point tolerance, and a disagreement is a bug worth hunting (Chapter 20 explains why "identical" becomes "within tolerance" once real arithmetic enters).

Phase 3 — Capture the Recipe in a Script

Typing those commands by hand invites exactly the drift we are trying to prevent. Capture them in a small script, build.sh, that takes the profile as an argument:

#!/usr/bin/env bash
# build.sh — build the heat solver in a chosen profile.
# Usage:  ./build.sh dev       (development: checks on, for writing and testing)
#         ./build.sh release   (release: optimized, for timed and production runs)
set -e                                  # stop at the first error

STD="-std=f2018 -Wall"                  # the non-negotiable floor, both profiles

if [ "$1" = "release" ]; then
    FLAGS="$STD -O2"
else
    FLAGS="$STD -g -fcheck=all"
fi

echo "building with: gfortran $FLAGS"   # print the exact recipe used
gfortran $FLAGS heat.f90 -o heat

Now a build is one unambiguous command, and — because of the echo line — it announces the exact flags it used every time it runs, which is the first half of reproducibility:

$ ./build.sh dev
building with: gfortran -std=f2018 -Wall -g -fcheck=all
$ ./build.sh release
building with: gfortran -std=f2018 -Wall -O2

The set -e line matters more than it looks: it makes the script stop immediately if the compile fails, rather than plowing on and running a stale executable — the scripting equivalent of not confusing "it built" with "it built this time." This script is deliberately minimal; it will grow into a real build as the project does, and around Chapter 16 you will graduate from a hand-written script to the Fortran Package Manager, fpm, which automates the compile–link cycle for a multi-file project. For now, a dozen lines of shell captures the design.

Phase 4 — Portability Across Three Platforms

Because heat.f90 is standard-conforming free-form Fortran and gfortran exists everywhere, the source needs no changes to move between platforms. Only the surrounding shell differs:

Platform Compiler source (§2.1) Runs build.sh?
Linux apt/dnf/pacman, or a cluster module load yes, directly
macOS Homebrew brew install gcc yes, directly (bash/zsh)
Windows (WSL) apt install gfortran inside WSL yes — it is Linux
Windows (MSYS2) pacman -S …-gcc-fortran yes, in the MSYS2 shell
Windows (native PowerShell) MSYS2/w64devkit gfortran on PATH not directly — run the two gfortran lines, or write a build.ps1

The single portability caveat worth flagging is the last row: a plain Windows PowerShell prompt does not run a bash script, so a Windows-native user either works inside WSL or MSYS2 (recommended — you get the whole Unix toolchain) or writes a two-line PowerShell equivalent that issues the same gfortran command. The compiler command itself is identical on every platform — which is the entire payoff of writing to the standard with a portable compiler. Note also that the executable is heat on Linux and macOS and heat.exe on Windows; the build command is the same, but you run ./heat versus heat.exe.

Phase 5 — Record the Environment

The script records the flags; reproducibility also needs the compiler. Capture it once and keep it with the project — a BUILD.md note, or a comment at the top of README.md:

$ gfortran --version | head -n 1
GNU Fortran (Ubuntu 13.2.0-4ubuntu3) 13.2.0

Now the project records everything needed to regenerate any executable: the source (heat.f90), the exact flags (announced by build.sh and fixed in the script), and the exact compiler (gfortran 13.2.0). That triple — source, flags, compiler version — is the reproducibility minimum for any computational result, and you have established it on day one, when it is free. When you eventually report a simulation result and a reviewer asks "how do we reproduce this?", the answer is already written down. We formalize this discipline, and automate it, in Chapter 37.

Discussion Questions

  1. Both profiles share -std=f2018 -Wall. Construct the argument for why standard-conformance and warnings belong in the release build too, even though they cost nothing at run time and the release build is "for speed."
  2. The script defaults to the development profile when given no recognized argument (or none at all). Is that the safe default, or should an unrecognized argument be an error? Argue both sides. (Hint: which mistake is worse — accidentally building with checks on when you wanted them off, or off when you wanted them on?)
  3. Reproducibility here rests on three things: source, flags, and compiler version. Give a concrete scenario in which two of the three are identical but a result still fails to reproduce because the third differs.

Your Turn: Extensions

  • Option A. Write the build.ps1 PowerShell equivalent of build.sh for a Windows-native user, taking the same dev/release argument and issuing the same two gfortran commands. Confirm (by hand, from the banner) that it produces the identical output.
  • Option B. Extend build.sh with a third profile, debug, that adds -fbacktrace on top of the development flags (a preview of Chapter 13). What is the design principle for how many profiles is too many?
  • Option C. Add a clean argument that removes the built heat executable, and explain why a build system needs a way to force a fully fresh build — what stale state could otherwise mislead you?

Key Takeaways

  • A build is a design artifact, not an afterthought. Deciding your profiles, script, and reproducibility record on day one — when the program is a four-line banner — costs nothing and pays out for the life of the project.
  • The development and release profiles encode a real tradeoff (safety and diagnostics versus raw speed) on top of a shared, non-negotiable floor of standard-conformance and warnings. Naming that tradeoff is the design.
  • Standard-conforming Fortran plus a portable compiler makes the source portable for free; only the surrounding shell differs across Linux, macOS, and Windows.
  • Reproducibility is the triple source + flags + compiler version. Capture all three from the start, and "works on my machine" never becomes your confession.