33 min read

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.

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 — -O0 through -Ofast — and say what each rung actually does.
  • Use -march=native and -flto for real gains, and explain exactly why neither belongs in a binary you hand to someone else without thought.
  • Predict, and demonstrate, how -Ofast can turn 1.0 into 0.0, and connect that to non-associative floating-point arithmetic.
  • Map a gfortran recipe onto Intel ifx and NVIDIA nvfortran, 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 -O3 buys, what -Ofast costs your numerics, and how to record a build so a reviewer trusts it. Read the -Ofast pitfall 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 -O number as how much the compiler is allowed to rearrange your program while promising the answer stays the same. From -O0 to -O3, the compiler works harder but keeps that promise. -Ofast is 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 (-Ofast and its caveats). -Ofast enables -O3 and 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) + c as a + (b + c)), assume no NaN or Inf ever 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 — -Ofast changes results silently. Nothing warns you. The program compiles cleanly, runs without error, and prints 0.00 where the standard says 1.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 — -Ofast can 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 -Ofast build 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:

  1. Build and validate at -O2 (or -O3), which respect IEEE semantics.
  2. Then, if and only if you need more speed, try -Ofast and 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.
  3. Record which one you used (see §30.4). "We used -Ofast" is a material fact about a result.

🔗 Connection: -Ofast is -O3 plus -ffast-math. If you want the vectorization of -O3 but nothing that touches your numerics, that is exactly what -O3 alone gives you. The floating-point hazards -ffast-math exposes — non-associativity, cancellation, NaN handling — 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=native tells 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=native most rewards code that is already vectorizable — tight loops over contiguous real(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=native can enable fused multiply-add (FMA) instructions, which compute a*b + c in 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 -Ofast problem: 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 compiles heat_solver.f90 it cannot see inside heat_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 -flto at 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 -O3 for your Python loop. When a NumPy vectorized expression is not enough and you drop into a Python for loop 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 you import was 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 -O3 and 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=native crash on a different computer that runs the same OS? 3. Where must -flto appear — the compile step, the link step, or both?

Answers (1) You must have used -Ofast somewhere (or -ffast-math); plain -O0/-O2/-O3 preserve the result, so three different answers mean IEEE semantics were relaxed. (2) -march=native emits 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 of ifx; new work should target ifx.
  • ifx — the newer, LLVM-based Intel Fortran compiler, the go-forward tool. It shares Intel's optimizer heritage and command-line flags with ifort while 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 under ifx and 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 = ifort and FFLAGS = -O3 -xHost will 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 with fpm back in Chapter 16; per-compiler flag selection is one more reason that investment pays off.

🔄 Check Your Understanding. 1. Give the Intel ifx equivalents of gfortran's -march=native and -flto. 2. You port a validated gfortran code to ifx, 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) -xHost for -march=native; -ipo for -flto. (2) Intel's default relaxed floating-point model (-fp-model fast); add -fp-model precise to 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=all build. 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=all for production runs, once you trust the code, to get your speed back. Develop guarded; run lean.

The workflow, then, is a rhythm:

  1. Write and debug with the development build until the code is correct and the tests pass.
  2. Switch to the release build for real runs and for any timing you report.
  3. 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=all will expose, not a compiler bug. Suspect yourself first; the compiler is very rarely wrong, and -fcheck=all usually finds the real culprit in seconds.

🐛 Find the Bug. A colleague reports: "My solver gives the right answer at -O0 but 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 -O0 it happened to hold a benign value — often zero, by luck of the stack — but -O3 reorders and reuses registers and stack slots, so the garbage surfaces. Rebuild the "broken" -O3 case with -O0 -fcheck=all -finit-real=snan: the signaling-NaN initialization (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 -O2 and -O3 preserve 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 if branch 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 -flto captures 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_env functions 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=native was 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=native is 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 than native), 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=native is 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, LLVM flang) 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₀
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.

  1. (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.

  2. (Chapter 2) You compile with gfortran myprog.f90 -o myprog and 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`).

  3. (Chapter 27) Chapter 27 argued that the compiler can optimize Fortran aggressively because of the no-aliasing guarantee. How does that connect to whether -O3 is 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.

  4. (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.

  5. (Chapters 2 & 27, synthesis) A colleague times the solver at -O0 -fcheck=all, gets 40 seconds, then at -O3 -march=native gets 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.