You have spent three chapters earning speed the hard way. In Chapter 27
Prerequisites
- 2
- 20
- 27
- 28
- 29
Learning Objectives
- Explain what gfortran's -O0, -O1, -O2, -O3, and -Ofast levels change, and choose the right one for development versus production.
- Describe what -march=native and -flto do, and state precisely why each can be unsafe to ship.
- Predict when -Ofast will alter a program's numerical results, and trace the cause to IEEE floating-point arithmetic.
- Translate a gfortran optimization recipe into its Intel (ifx) and NVIDIA (nvfortran) equivalents.
- Assemble a reproducible release build that records its own compiler, flags, and version alongside its results.
- Apply profile-guided optimization to a branchy kernel, and judge when it is worth the added build complexity.
In This Chapter
- Overview
- Learning Paths
- 30.1 The gfortran Optimization Ladder
- 30.2 Beyond gfortran: Intel ifx, ifort, and NVIDIA nvfortran
- 30.3 Two Builds, Not One: Development versus Release
- 30.4 Profile-Guided Optimization and Reproducible Builds
- 30.5 Portability Across Compilers and Platforms
- Project Checkpoint
- Summary
- Spaced Review
- What's Next
Chapter 30: Compiler Flags and Platform-Specific Optimization
"Optimization hinders evolution." — Alan J. Perlis, Epigrams on Programming (1982)
Overview
You have spent three chapters earning speed the hard way. In Chapter 27 you learned why Fortran is fast — the compiler's freedom to optimize code that promises not to alias. In Chapter 28 you learned to measure, so that you tune what is slow rather than what you imagine is slow. In Chapter 29 you rewrote your hot loops — reordered them for column-major memory, blocked them for cache, structured them so the compiler could vectorize. Every one of those gains cost you thought and code.
This chapter is about the cheapest lever left, and the most misunderstood: the flags you type on the
command line. A single word — -O3 — can hand you a factor of several with no change to your source at
all. Another word — -march=native — can add more, by letting the compiler use the exact instruction set
of the chip in front of it. And a third — -Ofast — can hand you still more, while quietly changing the
answer your program computes, because it buys speed by relaxing the floating-point rules you studied in
Chapter 20. Free performance and
loaded performance sit one letter apart in the manual, and the whole job of this chapter is to teach you
which is which.
We will also step outside gfortran for the first time in the book. Real high-performance work happens on
Intel's ifx and NVIDIA's nvfortran as often as on GNU, and their flags are different words for mostly
the same ideas — until they are not, and a default you did not know about changes your results across
compilers. And we will close on the discipline that separates a benchmark from a fairy tale:
recording the flags, so that a number you report today can be reproduced by someone else — or by you,
next year, on a different machine. That discipline is the bridge into
Chapter 37, where
reproducibility becomes a testable property of your project.
In this chapter, you will learn to:
- Read gfortran's optimization ladder —
-O0through-Ofast— and say what each rung actually does. - Use
-march=nativeand-fltofor real gains, and explain exactly why neither belongs in a binary you hand to someone else without thought. - Predict, and demonstrate, how
-Ofastcan turn1.0into0.0, and connect that to non-associative floating-point arithmetic. - Map a gfortran recipe onto Intel
ifxand NVIDIAnvfortran, and spot the one Intel default that silently breaks cross-compiler agreement. - Keep two build profiles — a paranoid development build and a lean release build — and never confuse their timings.
- Record a build so completely that its results are reproducible, and apply profile-guided optimization when (and only when) it earns its keep.
Learning Paths
How to read this chapter by track. - 🔬 Scientist ("my results must be right and fast") — §30.1 and §30.4 are the core: what
-O3buys, what-Ofastcosts your numerics, and how to record a build so a reviewer trusts it. Read the-Ofastpitfall twice. - 📖 Standard — read straight through; this chapter is short and every section carries a rule you will reuse. §30.5 (portability) is where the standard's promises meet the compilers' realities. - 🔧 Legacy ("I build someone else's old code") — §30.3 (debug vs release) and §30.5 (portability across compilers) are your survival kit; old codes often assume one compiler's defaults. - ⚡ HPC ("I run on a named cluster") — §30.2 (Intel and NVIDIA) and the Project Checkpoint are for you:-xHost,-ipo, and the honest measurement of a real speedup on real hardware.
30.1 The gfortran Optimization Ladder
Every optimizing compiler gives you a dial that says, in effect, how hard should I try? In gfortran the
dial is the -O family, and you met its bottom and middle in
Chapter 2: -O0 for
development, -O2 for release. Now we climb the whole ladder and, more importantly, learn where the
rungs stop being free.
Here is the ladder, honestly described:
| Flag | What it means | When to use it |
|---|---|---|
-O0 |
No optimization (the default when you pass no -O). Compiles fastest; the machine code maps line-for-line to your source, so a debugger shows exactly where you are. |
Development and debugging. |
-O1 |
Basic optimizations that are cheap to perform and almost always help. | Rarely chosen explicitly; a stepping stone. |
-O2 |
A large, well-tested suite: inlining of small procedures, common-subexpression elimination, strength reduction, and more. The sensible default for code you want to run fast. | Most release builds. |
-O3 |
Everything in -O2 plus more aggressive transformations: heavier inlining, additional loop transformations, and aggressive auto-vectorization. Occasionally slower than -O2 (bigger code, more cache pressure) — so measure. |
Numerical kernels, after you have measured. |
-Ofast |
-O3 plus flags that relax IEEE floating-point compliance. Fast, and able to change your results. |
Only knowingly, with your eyes open (see below). |
-Os |
Optimize for small code size rather than speed. | Embedded targets; not our world. |
💡 Intuition: think of the
-Onumber as how much the compiler is allowed to rearrange your program while promising the answer stays the same. From-O0to-O3, the compiler works harder but keeps that promise.-Ofastis the rung where the promise itself is loosened — which is exactly why it gets its own warning label.
The crucial fact about -O0 through -O3 is one people find surprisingly reassuring: they do not
change what your program computes. They change how fast it computes it. The compiler is permitted to
reorder, inline, unroll, and vectorize only in ways that preserve the result the language standard says
your program must produce. So the same source, built at four different levels, prints the same numbers —
just at different speeds. Our first example makes that concrete.
program opt_levels
use, intrinsic :: iso_fortran_env, only: dp => real64
implicit none
real(dp) :: x(10) = [(real(i, dp), i = 1, 10)]
integer :: i
print '(a, f8.2)', 'sum of squares = ', sum(x**2)
end program opt_levels
The array holds $1, 2, \dots, 10$; the sum of squares is $1 + 4 + 9 + 16 + 25 + 36 + 49 + 64 + 81 + 100 = 385$. Now build it four ways and run each:
$ gfortran -std=f2018 -Wall -O0 opt_levels.f90 -o opt0 && ./opt0
sum of squares = 385.00
$ gfortran -std=f2018 -Wall -O2 opt_levels.f90 -o opt2 && ./opt2
sum of squares = 385.00
$ gfortran -std=f2018 -Wall -O3 -march=native opt_levels.f90 -o opt3 && ./opt3
sum of squares = 385.00
Identical output, three times over. The -O3 -march=native binary may have summed the squares four at a
time using vector instructions while the -O0 binary added them one at a time in a plain loop, but the
mathematics of integer-valued reals is exact here, so every build agrees to the last bit. This is the
normal case, and it is the whole reason optimization flags are safe to reach for: they buy speed, not
different answers. Keep that baseline firmly in mind, because the next flag is the exception that proves
the rule.
-Ofast and the price of relaxed IEEE arithmetic
Definition (
-Ofastand its caveats).-Ofastenables-O3and a group of flags — led by-ffast-math— that permit the compiler to violate strict IEEE 754 floating-point semantics in the name of speed. Concretely, it lets the compiler reassociate floating-point operations (treat(a + b) + casa + (b + c)), assume noNaNorInfever occurs, flush tiny denormal numbers to zero, and — for Fortran specifically — ignore the parentheses you wrote (-fno-protect-parens). Each relaxation can speed up real code. Each can also change the number your program prints.
Why would reassociation change anything? Because, as you learned in Chapter 20, floating-point addition is not associative. Rounding happens at every step, so the order of operations is part of the answer. Here is the danger in four lines:
real(dp) :: a, b, c, result
a = 1.0e20_dp
b = -1.0e20_dp
c = 1.0_dp
result = (a + b) + c ! you wrote the parentheses on purpose
Trace it the way the IEEE rules — and therefore an -O2 build — require. The parentheses say: add a
and b first. $10^{20} + (-10^{20}) = 0$ exactly. Then $0 + 1 = 1$. So result is 1.0:
$ gfortran -std=f2018 -Wall -O2 reassoc.f90 -o reassoc && ./reassoc
result = 1.00
Now build the same source with -Ofast. Because -Ofast includes -fno-protect-parens, the compiler
is allowed to regroup your expression as a + (b + c). That evaluates $-10^{20} + 1$ first — but $1$ is
so much smaller than $10^{20}$ that it rounds away entirely, giving $-10^{20}$ — and then
$10^{20} + (-10^{20}) = 0$. The same source, on the same machine, can now print:
$ gfortran -std=f2018 -Wall -Ofast reassoc.f90 -o reassoc_fast && ./reassoc_fast
result = 0.00
⚠️ Common Pitfall —
-Ofastchanges results silently. Nothing warns you. The program compiles cleanly, runs without error, and prints0.00where the standard says1.00. If your code depends on the ordering of floating-point operations — compensated summation, catastrophic-cancellation-prone differences, a carefully conditioned algorithm from Chapter 20 —-Ofastcan quietly break it. The failure is worst precisely where it is hardest to notice: not a crash, just a wrong number, sometimes only in the last few digits, sometimes (as above) catastrophically. Never ship a scientific result from an-Ofastbuild you have not validated against a stricter build.
Whether the compiler actually reassociates in any given spot depends on the version and the surrounding
code, so you cannot predict the exact -Ofast output by reading the source — which is itself the point.
The safe rule is a decision procedure, not a number:
- Build and validate at
-O2(or-O3), which respect IEEE semantics. - Then, if and only if you need more speed, try
-Ofastand re-run your validation suite. If the results still pass your tolerance, and you understand why, you may use it. If they do not, you may not. - Record which one you used (see §30.4). "We used
-Ofast" is a material fact about a result.
🔗 Connection:
-Ofastis-O3plus-ffast-math. If you want the vectorization of-O3but nothing that touches your numerics, that is exactly what-O3alone gives you. The floating-point hazards-ffast-mathexposes — non-associativity, cancellation,NaNhandling — are the subject of Chapter 20; this flag is where that theory reaches out and changes a number on your screen.
-march=native: compiling for this chip
Definition (
-march=native). By default gfortran targets a conservative, generic version of your processor family, so the binary will run on any reasonably old CPU of that family.-march=nativetells the compiler instead to detect the exact CPU it is compiling on and to use every instruction that chip supports — most importantly its widest SIMD instruction set (SSE, AVX2, AVX-512, or a NEON/SVE variant on ARM). The generated binary is tuned for, and may only run on, chips with those instructions.
For numerical code this can be a real win, because the SIMD width is how many floating-point numbers the
processor adds or multiplies per instruction. A generic build might use 128-bit vectors (two real(dp) at
a time); an AVX-512 machine with -march=native can use 512-bit vectors (eight at a time). Combined with
the vectorizable loops you wrote in
Chapter 29, that is free width.
The catch is in the definition: the binary may not run elsewhere. Build with -march=native on your
AVX-512 workstation, copy the executable to an older cluster node that lacks AVX-512, and it dies with an
illegal instruction — the CPU literally does not know the opcode the compiler emitted.
⚡ Performance Note:
-march=nativemost rewards code that is already vectorizable — tight loops over contiguousreal(dp)arrays, which (thanks to column-major order and the no-aliasing rule) is exactly the shape of a good Fortran kernel. On memory-bound code that spends its time waiting for data (Chapter 28's distinction), wider vectors help far less, because the bottleneck is the memory bus, not the arithmetic units. Measure before you assume the flag helped.🔗 Connection:
-march=nativecan enable fused multiply-add (FMA) instructions, which computea*b + cin one step with a single rounding instead of two. That is faster and usually more accurate — but it means an FMA build can differ in the last bit from a non-FMA build of the same source, even without-Ofast. If you need identical results across machines with and without FMA, that is a reproducibility constraint you must record (§30.4). This is a subtler cousin of the-Ofastproblem: better hardware, honestly used, can still change your last digit.
-flto: optimizing across file boundaries
Definition (
-flto/ link-time optimization). Normally the compiler optimizes each source file in isolation: when it compilesheat_solver.f90it cannot see insideheat_io.f90, so it cannot, for example, inline a function that lives in the other file. Link-time optimization (-flto) defers the heavy optimization to the link step, where the whole program is visible at once. The compiler can then inline across files, propagate constants between modules, and delete code that no file actually uses. You pass-fltoat both the compile and the link commands.
For a modular Fortran program — and yours is modular, split across kinds, heat_types,
heat_solver, and heat_io since Chapter 8 — this matters, because Fortran encourages you to put small
procedures in modules and use them everywhere. Without -flto, a one-line accessor in another module is
a real function call every time. With -flto, the linker can inline it away as if you had written it
inline by hand.
$ gfortran -std=f2018 -Wall -O3 -flto -c kinds.f90
$ gfortran -std=f2018 -Wall -O3 -flto -c heat_solver.f90
$ gfortran -std=f2018 -Wall -O3 -flto kinds.o heat_solver.o heat.f90 -o heat
The costs are real but modest: -flto builds are slower to link and use more memory during the link,
and — because the whole program is optimized together — a debugger has a harder time. It is a release
flag, not a development one. Its payoff is largest for programs split into many small modules with hot
procedures crossing file boundaries, which describes most serious Fortran codebases.
🐍 Python Comparison: there is no
-O3for your Python loop. When a NumPy vectorized expression is not enough and you drop into a Pythonforloop over a million elements, the interpreter executes it one bytecode at a time — no inlining, no vectorization, no link-time analysis, because there is no compiler making those decisions. This is the gap the whole book has been pointing at: the flags in this chapter are levers that simply do not exist for interpreted code, which is why the hot kernel goes in Fortran and Python orchestrates it (as you did with f2py in Chapter 15). Ironically, the pip wheel youimportwas built generic — never-march=native— so a from-source build tuned to your CPU can beat it.🔄 Check Your Understanding. 1. You build a kernel at
-O0,-O2, and-O3and get three different numbers in the last two digits. Which flag did you actually use, and what did you forget? 2. Why can an executable built with-march=nativecrash on a different computer that runs the same OS? 3. Where must-fltoappear — the compile step, the link step, or both?
Answers
(1) You must have used-Ofastsomewhere (or-ffast-math); plain-O0/-O2/-O3preserve the result, so three different answers mean IEEE semantics were relaxed. (2)-march=nativeemits instructions specific to the build machine's CPU; a different CPU without those instructions raises an illegal-instruction fault regardless of the OS. (3) Both — it changes how objects are compiled and how they are linked.
30.2 Beyond gfortran: Intel ifx, ifort, and NVIDIA nvfortran
The whole book compiles with gfortran because it is free and everywhere, but production HPC runs on other compilers too, and on real hardware their vendor compilers are frequently faster. You do not need to master them, but you must be able to translate: the good news is that the ideas transfer directly, only the spellings change.
Intel's Fortran compiler ships in the free oneAPI toolkit. It comes in two forms, and the distinction matters right now:
ifort— the classic Intel Fortran compiler, decades old and long the gold standard for speed on Intel CPUs. Intel has announced its deprecation in favor ofifx; new work should targetifx.ifx— the newer, LLVM-based Intel Fortran compiler, the go-forward tool. It shares Intel's optimizer heritage and command-line flags withifortwhile building on the LLVM backend.
Their optimization flags are the same ideas you just learned, wearing Intel names:
| Idea | gfortran | Intel ifx/ifort |
Roughly what it does |
|---|---|---|---|
| Solid release optimization | -O2 |
-O2 |
Safe, strong optimization. |
| Aggressive optimization | -O3 |
-O3 |
Heavier loop transforms and vectorization. |
| Tune for the build machine's CPU | -march=native |
-xHost |
Use the host CPU's instruction sets (e.g. AVX-512). |
| Whole-program / cross-file optimization | -flto |
-ipo |
Interprocedural optimization across files. |
| Tell me what the optimizer did | -fopt-info |
-qopt-report |
Emit a report of vectorization and inlining decisions. |
⚠️ Common Pitfall — Intel's floating-point default is not gfortran's. Here is the gotcha that ambushes people who assume "same source, same answer." Historically the Intel compilers default to a relaxed floating-point model (
-fp-model fast) — meaning that out of the box, Intel behaves a bit like gfortran's-Ofast, reassociating and contracting floating-point operations, while gfortran defaults to strict IEEE. The same program, unchanged, can therefore print slightly different numbers underifxand gfortran for no reason you wrote. To get gfortran-like strictness on Intel, add-fp-model precise(or-fp-model strict). This single default is behind a large fraction of "why don't my results match across compilers?" mysteries. (Exact default naming and behavior have shifted across Intel versions; verify against your compiler's docs — this is flagged as version-dependent.)📖 The lesson generalized. A compiler flag is a contract about how hard to optimize and how much IEEE fidelity to keep. Every serious compiler offers the same two knobs — an optimization level and a floating-point-strictness setting — but they ship with different defaults. Portability across compilers (§30.5) is largely the discipline of setting both knobs explicitly instead of inheriting whatever the vendor chose.
NVIDIA's nvfortran comes from the NVIDIA HPC SDK (the descendant of the PGI compilers) and its
reason to exist is the GPU. Alongside ordinary CPU optimization it can compile Fortran to run on NVIDIA
graphics processors, through OpenACC directives and CUDA Fortran — the subject of
Chapter 35. Its flags again rhyme
with what you know: an aggregate -fast (a bundle akin in spirit to a strong -O recipe), -Minfo to
report what the optimizer and GPU offloader did, and -acc/-gpu to target the device. We will not use
it until Part VIII; for now, simply know it exists, that it is the usual route onto NVIDIA GPUs from
Fortran, and that (its exact flag set is documented in the NVIDIA HPC SDK reference — treat specifics
here as orientation, not gospel; flagged as such).
🔧 Modern vs Legacy — portable flags, not hardcoded ones. An old Makefile that reads
FC = ifortandFFLAGS = -O3 -xHostwill not even parse under gfortran, and vice versa. Modern build tools (fpm, CMake) let you name a profile — "release", "debug" — and choose the flags per compiler, so one project builds correctly under all three. You set your project up withfpmback in Chapter 16; per-compiler flag selection is one more reason that investment pays off.🔄 Check Your Understanding. 1. Give the Intel
ifxequivalents of gfortran's-march=nativeand-flto. 2. You port a validated gfortran code toifx, change nothing, and the numbers drift in the last digits. What is the single most likely cause, and the one flag that fixes it?
Answers
(1)-xHostfor-march=native;-ipofor-flto. (2) Intel's default relaxed floating-point model (-fp-model fast); add-fp-model preciseto restore IEEE-strict behavior comparable to gfortran's default.
30.3 Two Builds, Not One: Development versus Release
By now you have met, scattered across chapters, every flag you need to run two builds of the same code — and running exactly two is one of the most valuable habits in this book. You saw the seed of it in Chapter 2 and used the development half all through Chapter 13. Here we make it a rule.
The development build catches your mistakes. It is slow and paranoid on purpose:
$ gfortran -std=f2018 -Wall -Wextra -g -O0 -fcheck=all -fbacktrace -ffpe-trap=invalid,zero,overflow \
heat.f90 -o heat_dev
Every flag earns its place. -g puts line numbers in the binary so a debugger and a backtrace can name
the guilty line. -O0 keeps the machine code aligned with your source so those line numbers are honest.
-fcheck=all inserts run-time checks for the classic Fortran sins — an array index out of bounds, a use
of an unallocated array — and stops with a message instead of corrupting memory. -fbacktrace prints the
call chain on a crash. -ffpe-trap turns a silent NaN into an immediate, locatable halt. This is the
build you develop in, every day, from the first line of code.
The release build runs fast. It strips the safety net away, because the net has a cost:
$ gfortran -std=f2018 -Wall -O3 -march=native -flto heat.f90 -o heat_run
⚠️ Common Pitfall — never time (or ship) a
-fcheck=allbuild. The run-time checks are not free: every array access now carries a bounds comparison, so a check-heavy build can run several times slower than the same code at-O2. Two consequences follow. First, if you benchmark a development build you will measure the checks, not your algorithm, and conclude your code is slow when it is merely guarded — a mistake that quietly poisons the profiling work of Chapter 28. Second, you must remove-fcheck=allfor production runs, once you trust the code, to get your speed back. Develop guarded; run lean.
The workflow, then, is a rhythm:
- Write and debug with the development build until the code is correct and the tests pass.
- Switch to the release build for real runs and for any timing you report.
- If a release run misbehaves — a crash that only appears at
-O3, a wrong number — go back to the development build to diagnose it. A bug that surfaces only under optimization is almost always a latent bug in your code (an uninitialized variable, an out-of-bounds access, an aliasing violation) that-O0 -fcheck=allwill expose, not a compiler bug. Suspect yourself first; the compiler is very rarely wrong, and-fcheck=allusually finds the real culprit in seconds.
🐛 Find the Bug. A colleague reports: "My solver gives the right answer at
-O0but garbage at-O3— the optimizer is broken!" Nine times out of ten they are wrong about the cause. What is the single most likely real explanation, and what one-line build change would confirm it?Answer
An uninitialized variable (or an out-of-bounds write). At-O0it happened to hold a benign value — often zero, by luck of the stack — but-O3reorders and reuses registers and stack slots, so the garbage surfaces. Rebuild the "broken"-O3case with-O0 -fcheck=all -finit-real=snan: the signaling-NaNinitialization (or the bounds check) will point straight at the unset variable. The optimizer merely revealed a bug that was always there. This is the everyday version of the "optimization exposes latent bugs" rule.🚪 Threshold Concept — the compiler is not lying to you. Once you internalize that
-O2and-O3preserve your program's meaning, a "bug that appears only under optimization" stops being a mystery and becomes a diagnosis: my program has undefined behavior, and optimization made it visible. This single reframing turns a class of terrifying, intermittent failures into a routine hunt with a known tool (-fcheck=all). Programmers who lack it blame the compiler and stay stuck; programmers who have it fix the real bug and move on. It is one of the most practically valuable ideas in performance work.
30.4 Profile-Guided Optimization and Reproducible Builds
Two topics live together here because they are the far end of the same spectrum — squeezing out the last few percent, and making sure the percentages you report are real.
Profile-guided optimization
Definition (profile-guided optimization, PGO). A two-phase build in which you first compile an instrumented version of your program, run it on representative input to record how it actually behaves — which branches are taken, which functions are hot, which loops iterate most — and then recompile using that recorded profile so the optimizer can make better decisions. Instead of guessing which
ifbranch is likely, or which function is worth inlining, the compiler knows, because it has seen your program run.
With gfortran the recipe is three steps:
$ gfortran -std=f2018 -O3 -fprofile-generate heat.f90 -o heat_instr # 1. build instrumented
$ ./heat_instr < typical_input.nml # 2. run on REAL data (writes .gcda)
$ gfortran -std=f2018 -O3 -fprofile-use heat.f90 -o heat_pgo # 3. rebuild using the profile
The payoff comes from decisions a static compiler cannot make well: laying out the hot path so branches
predict correctly, inlining exactly the functions that are actually called often, and moving rarely taken
error-handling code out of the hot cache lines. That means PGO helps branchy code far more than it helps
a tight numerical loop. A five-point stencil has no interesting branches — every iteration does the same
arithmetic — so PGO has little to work with. A code full of select case, conditionals, and irregular
control flow (a sparse solver's element loop, a physics code choosing a material model per cell) is where
PGO earns its keep.
⚡ Performance Note — is PGO worth it? Be honest about the cost. PGO doubles your build complexity (build, run, rebuild), and the profile is only as good as the input you ran on — profile on unrepresentative data and you can pessimize the real workload. For most people, most of the time,
-O3 -march=native -fltocaptures the bulk of the available speedup and PGO adds a few percent for a lot of ceremony. Reach for it when profiling (Chapter 28) shows a branchy hot spot, when those few percent matter (a code that runs for a month), and when you can automate the three-step build. Otherwise, skip it without guilt.
Reproducible builds: record the flags
Here is a truth that the previous three sections make unavoidable: a performance number without its
build recipe is meaningless. You now know that the same source can run at a dozen speeds and print more
than one answer depending on -O2 versus -Ofast, -march=native versus generic, gfortran versus ifx
with its relaxed default. So a claim like "the solver does 12 million cell-updates per second" is not a
fact about your code — it is a fact about your code plus a build plus a machine, and without the build
and the machine it cannot be checked, compared, or trusted.
Definition (reproducible build). A build whose exact inputs — compiler name and version, every flag, the target architecture, and the library versions linked — are recorded so precisely that another person (or you, later) can reconstruct the same binary and reproduce the same results. Reproducibility is not a nicety in science; it is the property that makes a computational result evidence rather than an anecdote.
The good news is that Fortran can make a binary record its own provenance. The standard intrinsic
module iso_fortran_env provides two functions — compiler_version() and compiler_options() — that
return, at run time, the compiler that built the program and the exact flags it was built with. Print them,
and every run of your code stamps its own build recipe into its output:
program provenance
use, intrinsic :: iso_fortran_env, only: dp => real64, &
compiler_version, compiler_options
implicit none
real(dp) :: x(10) = [(real(i, dp), i = 1, 10)]
integer :: i
print '(a)', '=== build provenance ==='
print '(2a)', 'compiler: ', compiler_version()
print '(2a)', 'options : ', compiler_options()
print '(a, f8.2)', 'checksum: ', sum(x) ! 1+2+...+10 = 55
end program provenance
The checksum line is deterministic — $1 + 2 + \dots + 10 = 55$, so it prints 55.00 on every correct
build, and a reader who gets a different value knows something is wrong. The compiler and options
lines are environment-dependent by design — that is the whole point — so they will read something like
the following (the exact text depends on your compiler and the flags you passed, which is precisely the
information being captured):
=== build provenance ===
compiler: GCC version 13.2.0
options : -std=f2018 -Wall -O3 -march=native -flto
checksum: 55.00
Now a log file or a results header carries its own recipe. Combine that with recording the machine (the
CPU model matters when you used -march=native) and the library versions (which BLAS did you link in
Chapter 21?), and your
performance claims become checkable.
🔗 Connection: recording the build is the first step of reproducibility, and reproducibility is a full engineering discipline — version control, continuous integration that builds under more than one compiler, regression tests that pin your results to a tolerance — developed in Chapter 37. What you learn here (record the flags; stamp the binary) is the habit that chapter turns into infrastructure. Get in the habit now: every timing you write down gets its flags written down beside it.
🔄 Check Your Understanding. 1. Why does profile-guided optimization help a branchy solver more than a tight stencil loop? 2. Name the two
iso_fortran_envfunctions that let a program report its own build, and say what each returns. 3. A paper reports "3.2× speedup." What three pieces of information must accompany that number for it to be reproducible?
Answers
(1) PGO improves branch layout, inlining, and hot/cold code placement — decisions that only matter when there are branches and varied call frequencies; a stencil has none. (2)compiler_version()returns the compiler and version string;compiler_options()returns the exact flags the program was compiled with. (3) The compiler and version, the full flag list for both builds being compared, and the machine/CPU (especially if-march=nativewas used) — plus, ideally, the baseline it is 3.2× faster than.
30.5 Portability Across Compilers and Platforms
The last section is a warning wearing the clothes of a checklist. Everything that makes a flag powerful makes it a portability hazard, and a code you cannot build on the next machine is a code with a short life.
-march=native is the classic trap. It is perfect for a benchmark on the machine you are sitting at
and wrong for anything you distribute, because the resulting binary encodes the instruction set of that
CPU. Three honest options:
- Building and running on the same machine (a personal workstation, or a cluster node identical to the
build node)?
-march=nativeis ideal — you get every instruction the chip has. - Distributing to unknown machines? Drop it, or choose a conservative baseline architecture that you
know every target supports (for example a named, older
-march=value rather thannative), trading a little peak speed for a binary that runs everywhere. - Building on the cluster, for the cluster? Many HPC sites recommend compiling on the same node type you
will run on, precisely so
-march=nativeis safe. Check the site's guidance.
Do not hardcode aggressive flags into shipped build files. A published fpm.toml, Makefile, or
CMake file that bakes in -march=native will build a broken binary for anyone whose CPU differs. Put such
flags in a profile the user opts into (fpm build --profile release), or in a documented variable they
can override, never in the one recipe everyone gets.
Set the two knobs explicitly. §30.2 taught the deeper portability lesson: compilers differ most in
their defaults. A portable, reproducible build states its intent rather than inheriting it — an explicit
optimization level and an explicit floating-point model on every compiler — so that gfortran, ifx, and
nvfortran are all being asked for the same thing. The alternative is to discover, in review, that your
"identical" builds were never asking for the same numerics at all.
Rely on the standard, not on a compiler's quirks. The reason your code is portable at all is that you
wrote it to the 2018 standard, with -std=f2018 catching non-standard extensions, and chose your
precision explicitly with a kind parameter
(Chapter 3) rather than
trusting a default real to mean the same thing everywhere. Portable performance is built on portable
correctness; the flags only tune what the standard already guarantees.
⚡ Performance Note — build under two compilers on purpose. The single most effective portability habit is to compile your code with gfortran and one other compiler (Intel
ifx, LLVMflang) regularly — ideally in continuous integration (Chapter 37). Each compiler warns about different things and makes different default assumptions, so two compilers catch roughly twice the latent bugs and nonportable assumptions, long before a new platform does it for you at the worst possible moment. Portability, like performance, is not accidental — it is a practice.
The full flag reference — every flag in this chapter, plus the development, parallel, and library-linking flags — lives in Appendix C, which is the page to keep open while you build. This chapter was the narrative; the appendix is the lookup table.
Project Checkpoint
The solver is as fast as a single core can make it: profiled (Chapter 28), its stencil reordered and blocked (Chapter 29). This checkpoint asks nothing of your algorithm — it asks only that you build it right and measure honestly. Take the finished heat solver and compile it two ways: a baseline with optimization off, and a release build with everything this chapter taught you.
$ gfortran -std=f2018 -Wall -O0 heat.f90 -o heat_O0 # baseline
$ gfortran -std=f2018 -Wall -O3 -march=native -flto heat.f90 -o heat_fast # release
Run each, and record the wall-clock time (with the system_clock timing you built in Chapter 28) and
the flags beside it. The provided project-checkpoint.f90 does exactly this: it runs the canonical
5×5 two-step solve — whose field sums to a hand-verifiable 600.00, unchanged by any optimization level,
because -O0 through -O3 preserve results — many times over to create a timeable workload, then prints
the checksum and the elapsed time.
The speedup you observe is illustrative, not a promise. On a vectorizable stencil the release build
commonly runs on the order of several times faster than -O0 — a range, not a number, because it depends
entirely on your CPU, your grid size, and your compiler version. Report it the honest way:
| Build | Flags | Result checksum | Elapsed (illustrative) | Relative |
|---|---|---|---|---|
| Baseline | -O0 |
600.00 |
t₀ |
1× |
| Release | -O3 -march=native -flto |
600.00 |
≈ t₀ / (a few) |
several× faster |
Two lessons are the whole point. First, the checksum is identical across both builds — you bought
speed, not a different answer, exactly as §30.1 promised (had you used -Ofast, you would now check
whether it still read 600.00). Second, the number in the "Elapsed" column is worthless without the
"Flags" column beside it. Write them together, always. That coupling — result, flags, machine — is the
seed of the reproducible build system you will grow in
Chapter 37, and it is the
difference between a benchmark and a story. Add a BUILD.md to your heat-solver/ directory recording the
exact release command; your future self, chasing a performance regression, will thank you.
Summary
This chapter was about the cheapest and most misread lever in performance work — the command line.
| Idea | The short version |
|---|---|
The -O ladder |
-O0 (debug) → -O2 (safe release default) → -O3 (aggressive, measure it). All preserve your results. |
-Ofast |
-O3 + -ffast-math: fast, but relaxes IEEE and can change your numbers (it can turn 1.0 into 0.0). Validate, then record that you used it. |
-march=native |
Compile for this CPU's instruction set (wider SIMD). Real speed on vectorizable code; the binary may not run on a different chip. |
-flto |
Link-time optimization — inline and analyze across files. Pass it at compile and link. A release flag. |
| Other compilers | Intel ifx/ifort: -O3, -xHost (=native), -ipo (=lto), -qopt-report. NVIDIA nvfortran targets GPUs. Intel's FP default is relaxed — add -fp-model precise. |
| Two builds | Develop with -g -O0 -fcheck=all -fbacktrace; release with -O3 -march=native -flto, checks removed. Never time a -fcheck=all build. |
| PGO | Instrument → run on real data → rebuild. Helps branchy code; little help for a tight stencil. |
| Reproducibility | Record the compiler, flags, and machine with every result. compiler_version() and compiler_options() let the binary stamp its own recipe. |
The two things to memorize. First: -O0 through -O3 change speed, not answers — so a "bug that
only appears under optimization" is almost always undefined behavior in your own code, and -fcheck=all
will find it. Second: a performance number without its build recipe is not a measurement — always write
the flags down beside the time.
Spaced Review
Retrieval practice on two earlier chapters this one leans on — the flags you first met, and the optimization theory behind them.
-
(Chapter 2) What is the difference between a development build and a release build, and which single flag most makes a development build too slow to benchmark?
Answer
The development build (`-g -O0 -fcheck=all -fbacktrace`) is optimized for catching mistakes; the release build (`-O2` or `-O3 -march=native -flto`) is optimized for speed. `-fcheck=all` is the flag that makes the development build unfit for timing — its run-time bounds/allocation checks can slow the code several times over, so a timing of a checked build measures the checks, not your algorithm. -
(Chapter 2) You compile with
gfortran myprog.f90 -o myprogand it runs slowly. Which optimization level did you just use, and what should you add for a release run?
Answer
With no `-O` flag, gfortran defaults to `-O0` — no optimization. For a release run add at least `-O2` (and, once measured, consider `-O3 -march=native -flto`). -
(Chapter 27) Chapter 27 argued that the compiler can optimize Fortran aggressively because of the no-aliasing guarantee. How does that connect to whether
-O3is safe to turn on?
Answer
Because the language promises that procedure arguments do not alias, the compiler's aggressive reorderings at `-O2`/`-O3` are *valid* — they cannot be invalidated by hidden overlap between arrays, the way they could in C. The no-aliasing rule is part of *why* raising the optimization level preserves your results rather than risking them. -
(Chapter 27) Chapter 27 showed a 10× difference from loop order alone at a fixed optimization level. What does that tell you about the relationship between source-level optimization (Chapter 29) and flag-level optimization (this chapter)?
Answer
They are complementary, and source structure comes first. A compiler flag cannot rescue a loop that walks memory in the wrong (row-major) order — the cache misses dominate regardless of `-O` level. You fix the memory access pattern in the source (Chapter 29), *then* the flags in this chapter multiply the already-good code. Flags amplify good structure; they do not substitute for it. -
(Chapters 2 & 27, synthesis) A colleague times the solver at
-O0 -fcheck=all, gets 40 seconds, then at-O3 -march=nativegets 2 seconds, and reports a "20× optimization speedup." What is misleading, and what is the honest comparison?
Answer
The baseline is a *guarded debug* build; most of the 40 seconds is `-fcheck=all` overhead, not the absence of optimization. The honest optimization speedup compares `-O0` (no checks) against `-O3 -march=native` (no checks). The 20× conflates "removed the safety checks" with "optimized," which are two different effects — and the report is unreproducible anyway, because it omits the machine and the full flag lists.
What's Next
You have now made a single core as fast as it will go: the algorithm is measured and tuned, and the compiler is extracting every instruction the chip offers. That is the end of the serial road. The only lever left is more cores — and the whole of Part VIII is about pulling it. It opens with Chapter 31, which explains why the free lunch of ever-faster clock speeds ended around 2005, why performance now comes from parallelism, and — through Amdahl's Law — exactly how much speedup your solver can hope for before you write a single parallel line. Optimizing the serial code first, as you just did, was not a detour: parallelizing slow code merely wastes more processors. Now that one core is fast, let us make many of them work together.