Case Study 30.2: A Reproducible, Portable Release Build for the Solver
"An experiment is reproducible until another laboratory tries to repeat it." — Alexander Kohn (widely quoted; treat the wording as illustrative)
Executive Summary
Case Study 30.1 was diagnostic: you took a dishonest number apart. This one is constructive: you will design the build so the number is honest from the start. The heat solver is finished and fast on your machine. Now it must become a piece of software that another group — on a different CPU, with a different compiler — can build, run, and get the same science from. That is a design problem, and its ingredients are exactly this chapter's: two build profiles, an explicit floating-point contract across compilers, self-recording provenance, a defensible position on profile-guided optimization, and a manifest that ties a result to the recipe that produced it.
You will produce (1) a portable profile table that maps "debug" and "release" onto gfortran, Intel ifx,
and NVIDIA nvfortran; (2) a compilable build_info module the solver uses to stamp every run; (3) a
cross-compiler agreement test that checks results to a tolerance rather than demanding bit-exactness; and
(4) a decision on PGO with the reasoning written down. No solver physics changes — this is entirely about
building it right.
Skills applied
- Designing debug and release profiles and mapping them across compilers (§30.2, §30.3).
- Setting the floating-point knob explicitly so compilers agree (§30.2 Intel default; §30.5).
- Stamping a binary with its own provenance for reproducibility (§30.4).
- Judging profile-guided optimization on cost/benefit (§30.4).
- Trading peak speed for portability with -march=native (§30.5).
Background
The solver builds cleanly under gfortran with -O3 -march=native -flto, validated against the analytical
steady state to a tight tolerance. A partner lab will run it on an Intel cluster with ifx, and a third
collaborator wants to try the NVIDIA compiler as a step toward GPU work later
(Chapter 35). Three compilers,
three CPUs, one source, one science. The danger is not that the code fails to compile — it is standard
Fortran 2018 and it will — but that it compiles into three subtly different programs because each
compiler's defaults differ, and nobody wrote the differences down.
Phase 1 — Define two profiles, portable across three compilers
The foundation is the two-build discipline of §30.3, generalized so that "debug" and "release" mean the same intent on every compiler even though the spellings differ. We write it as a table the whole project agrees on:
| Intent | gfortran | Intel ifx |
NVIDIA nvfortran |
|---|---|---|---|
| debug — catch mistakes | -g -O0 -fcheck=all -fbacktrace |
-g -O0 -check all -traceback |
-g -O0 -Mbounds -traceback |
| release — run fast | -O3 -march=native -flto |
-O3 -xHost -ipo |
-fast -Minfo |
| FP contract — IEEE-strict | (default is strict) | -fp-model precise |
-Kieee |
Two design decisions are encoded here. First, every profile names an explicit floating-point contract,
because §30.2 showed that Intel defaults to a relaxed model — so -fp-model precise is not optional
padding, it is what makes ifx agree with gfortran's default. (The gfortran column is blank for the FP row
only because its default already is strict; we still state that fact in the manifest so no reader has to
know it.) Second, the release row is where portability bites: -march=native/-xHost bakes in the build
machine's instruction set, which is fine if we build on each target and a bug if we ship one binary
everywhere. We resolve that in Phase 5.
A caution on the specifics: the exact
ifxandnvfortranspellings above (-check all,-Mbounds,-Kieee,-fast) are given as orientation from the vendors' documentation and their PGI lineage; they have shifted across versions. Verify each against the compiler you actually have — the table's structure (debug / release / FP-contract, one column per compiler) is the durable design; the cells are details to confirm. This is flagged as version-dependent.
Phase 2 — Make the binary record its own build
Reproducibility begins the moment a run can say how it was built. We give the solver a small module whose one job is to stamp provenance into any output stream — the terminal, a log file, or the header of a results file.
module build_info
use, intrinsic :: iso_fortran_env, only: output_unit, &
compiler_version, compiler_options
implicit none
private
public :: stamp_build
contains
! Write the compiler and flags that built this binary to `unit` (default: stdout).
subroutine stamp_build(unit)
integer, intent(in), optional :: unit
integer :: u
u = output_unit
if (present(unit)) u = unit
write(u, '(a)') '# --- build provenance ---'
write(u, '(2a)') '# compiler: ', compiler_version()
write(u, '(2a)') '# options : ', compiler_options()
end subroutine stamp_build
end module build_info
program run_with_stamp
use build_info, only: stamp_build
use, intrinsic :: iso_fortran_env, only: dp => real64
implicit none
real(dp) :: result
call stamp_build() ! header for every run's log
result = 3.0_dp * 4.0_dp + 1.0_dp ! stand-in "science": 13.0, deterministic
print '(a, f8.2)', 'result = ', result
end program run_with_stamp
$ gfortran -std=f2018 -Wall -O3 -march=native -flto build_info.f90 -o stamped && ./stamped
# --- build provenance ---
# compiler: GCC version 13.2.0
# options : -std=f2018 -Wall -O3 -march=native -flto
result = 13.00
The result line is deterministic — $3 \times 4 + 1 = 13$ — so it reads 13.00 on every correct build,
under every compiler. The two # lines are environment-dependent by design: run the same program built
by ifx and they will name ifx and its flags instead. Every log file the solver writes now begins with
the recipe that produced it. When the partner lab emails you a strange result, the first three lines of
their log tell you exactly how their binary was built — often solving the mystery before you read the
fourth line.
Phase 3 — Cross-compiler agreement: to a tolerance, not the last bit
Here is the design decision that separates people who have shipped portable numerical code from those who have not: do you require the three compilers to agree bit-for-bit, or only to a scientific tolerance?
Demanding bit-exactness across compilers and CPUs is usually a mistake. As §30.1's FMA note explained, a
build that fuses a*b + c into one rounding will differ in the last bit from one that does not — and that
is better arithmetic, not a bug. Chase bit-exactness and you must forbid FMA, forbid -march=native,
and pin one compiler, throwing away real speed to make a checksum match. The right target for a physics
result is agreement to a tolerance you justify from the problem, not from the hardware.
So the validation harness compares each build's result against a reference within a relative tolerance:
program agree
use, intrinsic :: iso_fortran_env, only: dp => real64
implicit none
real(dp) :: gfort_val, ifx_val, tol
! Same source, two compilers: last-bit differences from FMA / instruction choice.
gfort_val = 2.7182818284590452_dp
ifx_val = 2.7182818284590455_dp
tol = 1.0e-12_dp ! justified by the problem, not the hardware
if (abs(gfort_val - ifx_val) <= tol * abs(gfort_val)) then
print '(a)', 'builds AGREE within tolerance'
else
print '(a)', 'builds DIFFER -- investigate flags'
end if
end program agree
The two values differ by about $3\times10^{-16}$; the tolerance is $10^{-12}\times 2.718 \approx 2.7\times10^{-12}$. Since $3\times10^{-16}$ is far smaller, the builds agree within tolerance:
$ gfortran -std=f2018 -Wall -O2 agree.f90 -o agree && ./agree
builds AGREE within tolerance
This is the regression-test pattern of
Chapter 37 in miniature: pin
the science to a tolerance, let the last bits float. If instead the harness printed DIFFER, that is
your signal to hunt for a flag mismatch — most likely a compiler running its relaxed FP default when you
meant strict, exactly the Intel gotcha from §30.2.
Design rule. Cross-compiler portability means agreement to a stated tolerance with an explicit FP contract, not identical bits. Write the tolerance into the test; write the FP flag into the profile.
Phase 4 — Should this project use PGO?
Profile-guided optimization is on the table because a collaborator read that it "can add 10–20%." We make the decision the way §30.4 says to: by asking where the solver's time goes.
The hot path is the five-point stencil — a branch-free sweep of arithmetic over contiguous arrays. PGO's strengths (branch layout, hot/cold splitting, call-frequency-driven inlining) have almost nothing to work with there. The branchy part of the solver is the per-step boundary-condition dispatch and the occasional I/O checkpoint, which together are a sliver of the runtime. So the expected PGO payoff is small, and it comes at the cost of a three-step instrument-run-rebuild pipeline that every collaborator would have to reproduce — multiplying the build complexity across three compilers.
Decision: defer PGO. We record the reasoning so it is a decision, not an omission:
PGO decision (2026-07): DEFERRED.
Rationale: runtime is dominated by a branch-free stencil; PGO chiefly helps branchy
control flow, of which the solver has little. Expected gain < ~5%, against a 3x build
pipeline replicated over three compilers. Revisit IF profiling (Chapter 28) later shows
a branchy hot spot, e.g. after adaptive time-stepping or per-cell material models land.
That paragraph is worth more than a hasty -fprofile-use: the next person who wonders "why aren't we using
PGO?" gets an answer, and a trigger for when to reconsider.
Phase 5 — The manifest and the shipped build
Finally, portability of the distributed artifact. Because release uses -march=native/-xHost, we do
not ship one binary. Instead we ship the source plus an fpm-style profile the user opts into
(Chapter 16), so each
site compiles for its own CPU, and we ship a manifest that pins everything a result depends on:
Heat solver — reproducibility manifest
---------------------------------------
Source : git commit <hash>, Fortran 2018
Profiles : debug / release / FP-contract per the Phase 1 table (one column per compiler)
Release : gfortran -O3 -march=native -flto | ifx -O3 -xHost -ipo -fp-model precise
FP contract: IEEE-strict on every compiler (gfortran default; ifx via -fp-model precise)
Build rule : compile release ON the target node (so -march=native/-xHost is safe)
Validation : agree-to-tolerance vs analytical steady state, tol = 1e-12 relative
Provenance : every run stamps compiler_version()/compiler_options() (Phase 2)
PGO : deferred, with recorded rationale (Phase 4)
The two load-bearing lines are "compile release ON the target node" — which turns -march=native from a
portability trap into a portability feature — and "IEEE-strict on every compiler," which is the one
sentence that makes three compilers compute the same science. Everything else is bookkeeping, and the
bookkeeping is what makes it reproducible.
Discussion Questions
- Why does naming an explicit floating-point contract matter more for a multi-compiler project than for a gfortran-only one? What single default would silently break agreement if left unstated?
- The design ships source plus a profile rather than a prebuilt binary. What did we gain, and what did we
ask of the user in return? When would shipping a conservative-
-marchbinary be the better trade? - Phase 3 chose agreement-to-tolerance over bit-exactness. Construct a scenario (hint: a chaotic or ill-conditioned system) where even a tolerance-based test is hard to satisfy across compilers, and say what you would do about it.
- The PGO decision was "defer, and here is the trigger to revisit." Why is a recorded deferral more valuable to a future maintainer than simply not mentioning PGO at all?
Your Turn: Extensions
Option A (design). Extend the Phase 1 profile table with a fourth column for the LLVM flang compiler.
Research (or reason about) its optimization, bounds-checking, and floating-point flags, and mark which cells
you are confident about versus which you would verify before trusting. Then state how adding flang to CI
would harden the project.
Option B (build). Turn the build_info module into a write_results_header procedure that stamps
provenance plus the run's grid size, step count, and a timestamp into the header of the solver's output
file — so a results file is self-describing. Sketch the interface and the exact lines it would write.
Option C (optimize). Suppose profiling later reveals that a new adaptive-time-stepping feature added a branchy hot spot (the PGO trigger from Phase 4 has fired). Design the three-command gfortran PGO build for it, define what "representative input" means for a heat run with adaptive stepping, and describe the before/after measurement that would justify keeping PGO in the release profile.
Key Takeaways
- Portability is a design, not an accident: one profile table, mapped across compilers, with an explicit floating-point contract in every column.
- State the FP contract explicitly on each compiler — especially Intel, whose default is relaxed. This one flag is what makes different compilers compute the same science.
- Stamp every binary with its own provenance (
compiler_version/compiler_options) so every result carries its recipe. - Aim for agreement to a justified tolerance, not bit-exactness; chasing identical bits forbids FMA and
-march=nativeand costs real speed for no scientific gain. - Ship source plus an opt-in profile, and compile
-march=nativerelease builds on the target node — turning the portability trap into a portability feature. - Decide PGO on evidence, and record the decision (including a deferral and its trigger). A written "no, because…" is worth more than silence.