40 min read

> *"Show me your flowcharts and conceal your tables, and I shall continue to be mystified. Show me your

Prerequisites

  • 8
  • 9
  • 13
  • 16
  • 24
  • 28

Learning Objectives

  • Read the directory layout and module hierarchy of a large scientific Fortran code, and predict where a given piece of functionality lives before opening a file.
  • Name the five recurring architectural roles — driver, solver, physics, I/O, and utility modules — and classify an unfamiliar module by the job it does.
  • Compare the build systems real Fortran codes use (Make, CMake, Meson, fpm), read a build file, and explain what a build configuration captures and why it must be recorded.
  • Navigate a hundred-thousand-line codebase you have never seen: find the entry point, build the module map, and use grep and tags to follow a name instead of reading top to bottom.
  • Read someone else's Fortran efficiently by locating where state lives and following the data flow, and predict where a solver spends its time before profiling it.
  • Reorganize the heat solver from a flat pile of files into a real src/ + app/ + test/ package with an fpm.toml or Makefile, and draw its dependency graph.

Chapter 36: Anatomy of a Real Scientific Code — How Large Fortran Projects Are Organized

"Show me your flowcharts and conceal your tables, and I shall continue to be mystified. Show me your tables, and I won't usually need your flowcharts; they'll be obvious." — Frederick P. Brooks Jr., The Mythical Man-Month

Overview

Everything you have written in this book so far, you could hold in your head. The heat solver is a handful of files; each chapter's examples fit on a screen. That is exactly right for learning a language, and it is nothing like the code you will meet on your first day in a research group. A production weather model, a computational-fluid-dynamics package, a quantum-chemistry code — these run to hundreds of thousands of lines, spread across hundreds of files, written by dozens of people over decades, most of whom you will never meet and some of whom are no longer alive. You will be asked to fix a bug in it, or add a term to the physics, or make one routine faster, in your first week, and the single most valuable skill you can bring is not knowing more Fortran — you already know enough Fortran. It is knowing how a large scientific code is organized, so that a hundred thousand lines feels like a city you can navigate rather than a wall you are staring at.

That is the subject of this chapter, and it is the pivot of the whole book. Up to now we have been learning the language. From here — Part IX — we learn the practice: how the language is used to build real things that other people trust and extend. This chapter is the map. We will tour the anatomy of a genuine scientific code so its directory layout stops looking arbitrary and starts looking inevitable; we will name the five architectural roles that recur in essentially every such code; we will survey the build systems you will meet in the wild and what they are actually for; and we will learn the concrete moves — the greps, the tags, the "follow the data" discipline — that let you find your way around code you did not write and cannot read in a sitting. Then, in the Project Checkpoint, you will do to your own solver exactly what a maintainer does to a growing code: reorganize it from a flat pile of files into a real package with a proper source tree and a build system, so that when the capstone arrives it is already shaped like software an engineer would respect.

In this chapter, you will learn to:

  • Read the directory layout and module hierarchy of a large scientific Fortran code, and predict where a feature lives before you open a single file.
  • Recognize the five recurring roles — driver, solver, physics, I/O, and utility modules — and classify an unfamiliar module by the job it does.
  • Compare the build systems real codes use (Make, CMake, Meson, fpm), read a build file, and say what a build configuration captures and why recording it is a scientific obligation, not a convenience.
  • Navigate a 100,000-line codebase you have never seen: find the entry point, build a module map, and follow a name with grep and tags instead of reading front to back.
  • Read someone else's Fortran by finding where state lives and following the data flow, and predict where the time goes before you profile.
  • Reorganize the heat solver into a real src/ + app/ + test/ package with a build file — the shape it will keep through the capstone.

Learning Paths

How to read this chapter by track. - 🔬 Scientist ("I inherited a code and need to change it") — this whole chapter is written for you, but §36.4 and §36.5 are the survival kit: how to find your way and read fast. Skim the build-system tour in §36.3 and return when you must compile the thing. - 📖 Standard — read straight through. Architecture is a topic the language reference cannot teach you, and it frames Chapters 37 and 38. - 🔧 Legacy ("the code I inherited is old and large") — §36.5 (where state lives) is doubly yours, because old codes hide state in COMMON and module variables; pair this chapter with Chapter 17. - ⚡ HPC ("I need to make it fast") — §36.5's "where the time goes" and the profiling callbacks to Chapter 28 are your entry point; you cannot optimize a code you cannot navigate.


36.1 A Tour of a Real Scientific Code

Open a large scientific Fortran code for the first time and your instinct will be to start reading files. Resist it. You would not try to understand a city by reading every street sign from one end to the other; you would look at a map, find the districts, and learn which district does what. A codebase has districts too, and in scientific Fortran they are astonishingly consistent from project to project. Learn the shape once and you can walk into an unfamiliar code and know, within minutes, roughly where everything is.

Here is a representative layout — a composite, idealized from the common structure of computational-fluid-dynamics, weather, and diffusion codes, not a copy of any one project. Read it as a floor plan, not a specific building:

awesome-flow/                    the project root
├── README.md                    what this is, how to build it, how to run it
├── LICENSE                      the terms (scientific codes are increasingly open source)
├── fpm.toml                     the build manifest  (or CMakeLists.txt / Makefile / meson.build)
├── doc/                         user guide, developer notes, FORD documentation config
├── src/                         THE LIBRARY — the large majority of the code lives here
│   ├── util/                    foundational helpers used by everything above
│   │   ├── kinds.f90                the precision kind dp
│   │   ├── constants.f90            physical & mathematical constants
│   │   └── logging.f90              error reporting, messages, assertions
│   ├── core/                    the central data structures and the solver engine
│   │   ├── grid_types.f90           the mesh / field derived types
│   │   └── solver.f90               the time-stepping / iteration engine
│   ├── physics/                 the science: one module per physical process
│   │   ├── diffusion.f90
│   │   ├── advection.f90
│   │   └── source_terms.f90
│   ├── io/                      reading configuration, writing results
│   │   ├── config_io.f90            parse the namelist / input deck
│   │   └── field_io.f90             write NetCDF / HDF5 / VTK output
│   └── driver/                  top-level orchestration
│       └── run.f90                  set up, run the time loop, finalize
├── app/                         the entry point(s): the actual program(s)
│   └── main.f90                     a thin `program` that calls driver/run
├── test/                        unit tests + regression tests
├── examples/                    runnable sample input decks
└── data/                        reference inputs / expected outputs for tests

Even without reading a line of the code, that tree tells a story. The bulk of the work lives under src/, divided into districts by responsibility, and the districts are stacked in layers: util/ at the bottom (things everyone needs), core/ and physics/ in the middle (the actual computation), io/ and driver/ toward the top (orchestration and the outside world), and a paper-thin app/main.f90 at the very top whose only job is to start the program. This is not an accident of taste; it is the direct, physical expression of the module-hierarchy discipline you built in Chapter 8: one responsibility per module, and depend downward, never sideways or up. The directory structure is the dependency graph made visible on disk.

💡 Intuition: the source tree is a topological sort you can see. Files in util/ depend on nothing in the project; files in driver/ depend on almost everything. If you ever want to know the compile order — or which parts you can understand in isolation — read the tree from the bottom up. kinds.f90 first, main.f90 last, exactly as Chapter 8 compiled the solver.

The module hierarchy mirrors the directory hierarchy, and in a well-organized code the two agree by convention: one module per file, the module named after the file (kinds.f90 holds module kinds), and the file placed in the district that matches its role. That one-module-per-file convention is worth its weight in gold to a newcomer, because it means the answer to "where is the code for module diffusion?" is always "in a file named diffusion.f90," and a code that honors it can be navigated with nothing but a file listing. When you meet a code that breaks it — three modules crammed in one file, or a module whose name has nothing to do with its filename — treat that as the first sign that navigation will be harder, and lean more heavily on the grep-and-tags techniques of §36.4.

🔗 Connection — real production codes, honestly. The codes named in Chapter 1 are the real versions of this floor plan. WRF (the Weather Research and Forecasting model), CESM (the Community Earth System Model), and Quantum ESPRESSO are all large, open-source, modular Fortran codes — hundreds of thousands to millions of lines — organized into source trees with exactly this flavor of separation: a foundation of shared kinds and utilities, a core set of data structures and solvers, a broad physics (or dynamics) layer with one module per process, and I/O and driver layers on top. I will not quote you specific internal module names or line counts, because those change between versions and I will not have you memorize a number that may be wrong — the structural fact is what transfers, and it is reliable: these codes are big, they are Fortran, and they are laid out in recognizable, responsibility-partitioned layers. Clone any one of them and you will recognize the districts. That recognition is the entire payoff of this section.

Two more features of the tree deserve a note now, because you will use them in every code you touch. test/ is where the code proves it still works — Chapter 37's subject — and its mere presence tells you whether the authors expect the code to be changed safely. And README.md at the root is the front door: in a healthy project it tells you what the code is, how to build it, and how to run the smallest example, which is where you should always start. A code with a good README and a populated test/ directory is a code that was written to be maintained, and that is a very different thing from a code that was merely written to run once and produce a figure for a paper. You will meet both. The techniques in this chapter help with either; they just help more with the first.


36.2 The Common Patterns: Driver, Solver, Physics, I/O, and Utility Modules

The districts of §36.1 are not arbitrary folders; each holds modules that play one of a small number of architectural roles. Learn these five roles and you can classify essentially any module in any scientific Fortran code by asking a single question: what job does this module do for the program? The roles are so consistent that naming them is most of the battle — once you can say "this is a physics module," you know what to expect inside it and what it may and may not depend on.

Definition (driver program). The driver is the top-level program unit that orchestrates a run but performs none of the science itself. It reads the configuration, sets up the data structures, runs the main loop by calling into the solver and physics, arranges for output, and shuts down cleanly. In our project, program heat is the driver. A well-written driver reads almost like an outline of the computation — setup, loop, output — precisely because it delegates every real task to a module below it. A newcomer who wants the thirty-second summary of what a code does reads the driver first, for the same reason you read a table of contents before a chapter.

Definition (solver module). A solver module owns the numerical engine — the algorithm that advances or converges the solution. It contains the time-stepping loop's inner machinery, the iteration scheme, the update rule: the "how we march forward" of the computation. Our heat_solver, with its step and stable_dt, is a solver module. It knows how to advance a field; it deliberately does not know where the field came from or where the results go.

Definition (physics module). A physics module encodes one piece of the science — a single physical process or term in the governing equations — as procedures that compute it, ideally as pure functions of their inputs. In a real code you find diffusion, advection, radiation, chemistry, each a module a domain scientist can read, verify against the textbook, and modify without touching the solver. In our project, the five-point Laplacian — laplacian, the discrete diffusion operator — is the physics, currently living inside heat_solver because the solver is still small. Splitting it out is exactly what you do as a code grows: the engine (solver) and the science (physics) become separate modules so a physicist and a numerical analyst can work without colliding.

Definition (utility module). A utility module provides foundational, general-purpose support used throughout the code and specific to no single part of the science: the precision kind, physical constants, string helpers, timers, error-reporting and logging, small mathematical helpers. Utility modules sit at the bottom of the dependency graph — everything may use them; they use almost nothing — which is why kinds, constants, and our timers live in util/. The test of a utility module is that you could lift it into an entirely different scientific code and it would still make sense.

The fifth role, the I/O module, you have been building since Chapter 7: heat_io, with read_config and write_field, isolates every conversation with the outside world — parsing the input deck, writing the output files — behind a clean interface, so the numerical core never touches a file directly. This isolation is worth insisting on. When the physics never opens a file and the I/O never computes a Laplacian, you can change your output format from text to VTK to NetCDF (Chapter 26's visualization, Chapter 25's data formats) by editing one module, and you can test the physics with no files at all.

Here is the whole pattern in miniature — a complete, compilable program with one module of each role, doing a tiny 1-D relaxation so you can see the roles collaborate without any real code getting in the way:

module util_kinds                 ! UTILITY: the precision foundation
  implicit none
  private
  public :: dp
  integer, parameter :: dp = selected_real_kind(15, 307)
end module util_kinds

module physics                    ! PHYSICS: one Jacobi relaxation sweep (the "science")
  use util_kinds, only: dp
  implicit none
  private
  public :: relax
contains
  pure function relax(u) result(v)          ! interior = average of neighbours; ends fixed
    real(dp), intent(in) :: u(:)
    real(dp) :: v(size(u))
    integer  :: i
    v = u                                   ! copy preserves the fixed endpoints
    do i = 2, size(u) - 1
       v(i) = 0.5_dp * (u(i-1) + u(i+1))    ! Jacobi: uses OLD neighbours
    end do
  end function relax
end module physics

module solver_core                ! SOLVER: the engine — iterate the physics n times
  use util_kinds, only: dp
  use physics,    only: relax
  implicit none
  private
  public :: solve
contains
  subroutine solve(u, n_iter)
    real(dp), intent(inout) :: u(:)
    integer,  intent(in)    :: n_iter
    integer :: k
    do k = 1, n_iter
       u = relax(u)                          ! the solver calls the physics; it never averages itself
    end do
  end subroutine solve
end module solver_core

module io_report                  ! I/O: everything that talks to the outside world
  use util_kinds, only: dp
  implicit none
  private
  public :: report
contains
  subroutine report(label, u)
    character(*), intent(in) :: label
    real(dp),     intent(in) :: u(:)
    print '(a)', label
    print '(*(f8.2))', u
  end subroutine report
end module io_report

program driver                    ! DRIVER: orchestrates; performs no science itself
  use util_kinds,  only: dp
  use solver_core, only: solve
  use io_report,   only: report
  implicit none
  real(dp) :: rod(5)
  rod = [0.0_dp, 0.0_dp, 0.0_dp, 0.0_dp, 100.0_dp]   ! right end held hot
  call report('initial:', rod)
  call solve(rod, 2)                                  ! two relaxation sweeps
  call report('after 2 sweeps:', rod)
end program driver
$ gfortran -std=f2018 -Wall -O2 example-01-architecture-roles.f90 -o roles && ./roles
initial:
    0.00    0.00    0.00    0.00  100.00
after 2 sweeps:
    0.00    0.00   25.00   50.00  100.00

Trace the output by hand, because it also traces the roles. The rod starts [0, 0, 0, 0, 100]. One Jacobi sweep replaces each interior node with the average of its old neighbours: node 2 becomes $\tfrac12(0+0)=0$, node 3 becomes $\tfrac12(0+0)=0$, node 4 becomes $\tfrac12(0+100)=50$, giving [0, 0, 0, 50, 100]. The second sweep gives node 2 $\tfrac12(0+0)=0$, node 3 $\tfrac12(0+50)=25$, node 4 $\tfrac12(0+100)=50$, so the final rod is [0, 0, 25, 50, 100]. Notice what each module did and, more tellingly, what it refused to do: the physics averaged neighbours but never looped over iterations; the solver looped but never averaged; the driver did neither, it only wired the pieces together. That refusal — each module doing exactly one job — is the whole art, and it is why a scientist can rewrite physics without understanding solver_core, and a numerical analyst can improve solver_core without reading physics.

⚡ Performance Note: the same separation that makes the code readable is what let Part VIII make it fast without a rewrite. Because the driver calls the solver through a stable interface, you can swap the serial solve for an OpenMP or MPI version behind that same interface, and the driver never knows whether it is running on one core or a thousand. Architecture is not opposed to performance; clean role boundaries are what make an optimized or parallel back-end a drop-in replacement instead of a demolition.

🐍 Python Comparison: these five roles are not a Fortran idea — they are how any well-structured scientific code is built, and you have seen them in Python: a main.py (driver) that imports a solver package, which calls physics functions, with utils and an io layer around them. What differs is the enforcement. Fortran's use ... only: and private/public make the role boundaries checkable by the compiler, and the module dependency graph is real — a physics module that tried to reach up into the driver simply would not compile. In Python the layering is a convention the linter hopes you followed; in Fortran it is a wall. This is the theme of Part II returning in the large: modern Fortran is a modern language, and its module system scales from your five-file solver to a million-line climate model without changing the idea.

🔄 Check Your Understanding. 1. You open an unfamiliar module and find it opens a file with open, reads a namelist, and returns the values. Which of the five roles is it, and which layer of the source tree should it live in? 2. In the miniature program, why is it a good sign that module physics contains no do k = 1, n_iter loop and no print statement? 3. Our laplacian currently lives inside heat_solver. Which role does it really play, and what would you gain by moving it into its own module as the code grows?

Answers (1) It is an I/O module; it belongs in io/ (or the I/O layer). Reading configuration is talking to the outside world, which the numerical core should never do directly. (2) Those absences mean the physics module does only physics — it computes one sweep as a pure function of its input, delegating iteration to the solver and output to the I/O module. Single responsibility is exactly what makes it independently readable and testable. (3) laplacian is a physics procedure (the discrete diffusion operator) living in a solver module. Splitting it into a diffusion (physics) module would let a domain scientist verify and change the operator without touching the time-stepping engine, and would let you add other physics (advection, sources) as sibling modules.


36.3 Build Systems in the Wild: Make, CMake, Meson, and fpm

A five-file program compiles with one gfortran command and the right file order, as Chapter 8 showed. A hundred-thousand-line code does not. It has hundreds of files with a tangled dependency graph, optional features that switch whole modules in and out, several supported compilers, debug and release builds, and external libraries to find and link. Working all of that out by hand, every time a file changes, is exactly the drudgery that build systems exist to eliminate. When you clone a scientific code, the build file is the second thing you read after the README — because it is the authoritative statement of what the code is made of and how it goes together.

Four build systems dominate Fortran, and you will meet all of them:

  • Make is the old workhorse: a Makefile lists targets, their dependencies, and the commands to build them, and make rebuilds only what changed. It is universal, it is on every Unix machine, and it is entirely manual — you (or a generator) must spell out that heat.o depends on heat_solver.mod, which depends on kinds.mod. Powerful, portable, and unforgiving; a hand-written Fortran Makefile that tracks module dependencies correctly is a genuine little artifact of engineering.
  • CMake is the dominant choice for large, cross-platform, multi-language scientific codes. You do not write the build commands; you describe the project in CMakeLists.txt — its targets, its dependencies, its options — and CMake generates the actual build files (Makefiles, Ninja, IDE projects) for whatever platform you are on. It handles finding libraries (LAPACK, NetCDF, MPI) and mixing Fortran with C and C++, which is why the biggest codes lean on it despite its complexity.
  • Meson is the modern challenger: a clean, fast, deliberately simple build system with first-class Fortran support, growing in the scientific community. It aims to give CMake's power with far less ceremony.
  • fpm, the Fortran Package Manager from Chapter 16, is the Fortran-native option and the one this book uses. It follows convention over configuration: put your library in src/, your programs in app/, your tests in test/, and fpm discovers every file, scans the use statements to derive the compile order automatically, and fetches dependencies from git — all from a short fpm.toml. For a new Fortran project with no heavy C++ interop, it is the least friction by a wide margin.

Definition (build configuration). A build configuration is the complete set of choices that determine how source is turned into a program: the compiler and its version, the optimization and debugging flags (Chapter 30), which optional code paths and preprocessor macros are enabled, the precision, and the exact versions of every external library linked in. A "debug build" and a "release build" of the same source are two different build configurations. The build system's job is to capture a configuration so it can be reproduced, and — as Chapter 37 will insist — recording the build configuration is part of recording a scientific result: the same source built two different ways can give two different numbers, so "which build?" is a question every reproducible run must answer.

Here is the same small project expressed two ways. First a hand-written Makefile, so you can read one when you meet it — note that it encodes the module compile order by hand, the very bookkeeping fpm does for you:

# Makefile — build the heat solver by hand. `make` builds; `make clean` tidies.
FC      = gfortran
FCFLAGS = -std=f2018 -Wall -O2          # the RELEASE build configuration
# For a DEBUG configuration, override at the command line:
#   make FCFLAGS="-std=f2018 -Wall -O0 -g -fcheck=all -fbacktrace"

OBJS = kinds.o timers.o heat_types.o heat_solver.o heat_io.o heat.o

heat: $(OBJS)                            # link step: all objects -> the executable
    $(FC) $(FCFLAGS) $(OBJS) -o heat

%.o: %.f90                               # compile any .f90 to a .o
    $(FC) $(FCFLAGS) -c $<

# Module dependencies — dictate compile ORDER (Chapter 8). Kept BY HAND:
heat_types.o : kinds.o
timers.o     : kinds.o
heat_solver.o: kinds.o heat_types.o
heat_io.o    : kinds.o heat_types.o
heat.o       : kinds.o heat_types.o heat_solver.o heat_io.o timers.o

clean:
    rm -f *.o *.mod heat

And the same project as an fpm.toml, which needs none of that dependency bookkeeping because fpm reads the use statements itself:

name = "heat-solver"
version = "0.1.0"
license = "MIT"
author = "Your Name"

[build]
auto-executables = true
auto-tests = true

# External libraries would appear here, pinned to a tag for reproducibility:
# [dependencies]
# stdlib = { git = "https://github.com/fortran-lang/stdlib", tag = "v0.7.0" }

The contrast is the lesson. The Makefile is explicit and portable but must be maintained — add a module and you must add its dependency line by hand, and a wrong line produces the "Cannot open module file" error of Chapter 8 at a baffling moment. The fpm.toml says almost nothing about how to build, because the convention supplies it. Neither is "better" in the abstract: a large code with deep C++ interop and forty build options will use CMake; a clean new Fortran library will use fpm; a decades-old code will have a Makefile you inherit and must not break.

A build configuration also lives partly inside the code, as compile-time choices captured in parameter constants — a version string, a debug switch, a default precision — so that flipping one value reshapes the build without hunting through the source. This is the pure-Fortran analog of the C preprocessor's #ifdef, and it uses the removable-check trick from Chapter 13:

module build_config              ! the code's own record of its build configuration
  implicit none
  private
  public :: version, debug, nx_default, ny_default
  character(*), parameter :: version    = '1.2.0'
  logical,      parameter :: debug      = .true.     ! flip to .false. for release
  integer,      parameter :: nx_default = 64, ny_default = 64
end module build_config

program show_config
  use build_config, only: version, debug, nx_default, ny_default
  implicit none
  print '(a)',        'build version : ' // version
  if (debug) then                                    ! a compile-time constant test
     print '(a, i0, a, i0)', '[debug] grid   : ', nx_default, ' x ', ny_default
  end if
  print '(a, i0)',    'grid cells    : ', nx_default * ny_default
end program show_config
$ gfortran -std=f2018 -Wall -O2 example-03-build-config.f90 -o showcfg && ./showcfg
build version : 1.2.0
[debug] grid   : 64 x 64
grid cells    : 4096

The output is exact: $64 \times 64 = 4096$ cells, and because debug is .true. the guarded line prints. Flip debug to .false. and recompile, and something quietly wonderful happens: if (debug) becomes if (.false.), a compile-time constant, and the optimizer deletes the dead branch entirely — the debug line costs nothing in the release build, exactly as the removable assertions of Chapter 13 did. That is a build configuration expressed in the language itself: one parameter decides whether a whole category of diagnostics exists in the compiled program.

⚠️ Common Pitfall — never let the build configuration be folklore. The single most common reproducibility failure in computational science is a result that cannot be regenerated because nobody recorded how it was built — which compiler, which flags, which library versions. -O3 -ffast-math and -O0 can give different last digits; a different LAPACK can shift an eigenvalue. Record the build configuration with the results (a build system that pins dependencies and a logged flag string is how), and treat "it works on my machine" as the confession it is. We make this a discipline in Chapter 37.

🔄 Check Your Understanding. 1. You add a new module boundary.f90 (used by heat_solver) to the project. What do you have to do for an fpm build to pick it up, and what would you have to do by hand in a Makefile? 2. Two colleagues run the identical source and get results differing in the last two digits. Give two build-configuration reasons this can happen. 3. What does flipping logical, parameter :: debug = .false. do to the compiled program, and why does it cost nothing at run time?

Answers (1) For fpm: nothing beyond dropping the file in src/ — fpm discovers it and reads its use statements to place it in the compile order. For a Makefile: add boundary.o to the objects list and write its dependency line(s) by hand (boundary.o : kinds.o …, and extend heat_solver.o's line to include boundary.o), or the build may compile out of order and fail with "Cannot open module file." (2) Different optimization flags (-O3 -ffast-math vs -O0 reassociate and fuse arithmetic, changing rounding) and different library versions (a different BLAS/LAPACK computes a result slightly differently); also a different compiler or version. All are part of the build configuration and must be recorded. (3) if (debug) becomes if (.false.), a compile-time constant, so the optimizer performs dead-code elimination and removes the guarded branch entirely — it is not in the executable, so it cannot cost anything at run time.


36.4 Navigating a 100,000-Line Codebase Without Getting Lost

You have the map (§36.1) and the vocabulary (§36.2). Now the concrete skill: you have been handed a URL, you have cloned a code you have never seen, and you have a task — fix a bug, add a term, speed up a routine. How do you start? Not by reading src/ alphabetically. You start by finding a few specific things, in a specific order, and letting the code's own structure lead you.

Read the README and the build file first. Five minutes here saves five hours later. The README tells you what the code computes and how to build the smallest example; the build file (§36.3) tells you what the code is made of — its modules, its options, its external dependencies. You are orienting, not understanding.

Find the entry point. Every program has exactly one, and in Fortran it is unmistakable: the program statement. In a code with one program (in app/main.f90, by convention) that is your "you are here" pin. grep for it:

$ grep -rn "program " src/ app/          # find the top-level program(s)
app/main.f90:3:program awesome_flow

Definition (entry point). The entry point of a Fortran program is its single program unit — the procedure execution begins in. Finding it is the first move in reading any code, because it is the root of the call tree: everything the program does, it does by starting here and calling downward. In a well-structured code the entry point is a thin driver, so reading it top to bottom gives you the shape of the whole run in a page.

Build the module map. The dependency graph you drew by hand for the solver in Chapter 8 is the single most useful mental model of a large code, and you can extract its skeleton mechanically. Every dependency is a use statement, so grepping for use across the tree gives you the edges of the graph:

$ grep -rn "^\s*use " src/ | grep -v "iso_" | head
src/core/solver.f90:4:  use grid_types, only: field_t
src/physics/diffusion.f90:3:  use kinds, only: dp
src/io/field_io.f90:5:  use grid_types, only: field_t
...

Definition (module map). A module map is the dependency graph of a code's modules — who uses whom — which is also its compile-order graph and its layering. You read it to answer "if I change this module, what else is affected?" (look up the arrows) and "what must I understand before I can understand this module?" (look down the arrows). It is the codebase's true table of contents, and unlike the directory tree it cannot lie, because the compiler enforces it.

Definition (source tree). The source tree is the directory hierarchy of a project's files. In a well-organized scientific code it is deliberately arranged to mirror the module map — util/ at the bottom, driver/ at the top — so that reading the folders top-down and reading the dependency arrows bottom-up describe the same structure. When the source tree and the module map agree, navigation is easy; when a code's folders are organized by something other than dependency (by author, by date, by accident), you fall back on the module map, because it is the one that is real.

Follow a name, do not read a file. This is the mental shift that turns a wall into a city. You almost never need to read a whole file; you need to answer a specific question — "where is field_t defined?", "who calls step?", "where does alpha get its value?" — and the tools answer those questions directly. Two tools do most of the work. grep finds every occurrence of a name across the tree. And a tags index (built by ctags, or provided live by a Fortran language server such as fortls in your editor) lets you jump from any use of a name straight to its definition with a keystroke:

$ ctags -R src/ app/            # build a tags index once
$ grep -rn "subroutine step" src/          # where is step defined?
src/core/solver.f90:22:  subroutine step(field, alpha, dt)
$ grep -rn "call step" src/ app/           # who calls it?
src/driver/run.f90:48:     call step(field, alpha, dt)

Definition-jumping and "find all callers" are the two moves you will make ten thousand times. With them, understanding a routine becomes tractable: jump to its definition, read it (not the file around it), jump to the definitions of the types and procedures it uses, and let the call graph pull you exactly as deep as your task requires and no deeper.

🚪 Threshold Concept — you navigate a large code by its architecture, not by reading it. The beginner's model of "understanding a codebase" is to read it, file by file, until it makes sense — which for a hundred thousand lines is hopeless and, worse, unnecessary. The professional's model is that a code is a graph you query: you never hold it all in your head, you hold the map — the five roles, the layered module dependency graph — and you use grep and tags to pull in exactly the few hundred lines your current question needs. Once you internalize that you can be productive in a million-line code while having read less than a percent of it, the size stops being frightening. Every maintainer of a large scientific code works this way. They have not read their code either. They have read its shape, and they know how to find the rest.

⚠️ Common Pitfall — the grep that lies. Two hazards trip up name-following in Fortran specifically. First, Fortran is case-insensitive, so step, Step, and STEP are the same name — grep case insensitively (grep -rin) or you will miss the FORTRAN 77 caller that shouted it. Second, a hit for a name is not always the right name: a generic interface, a renamed import (use m, only: s => step), or a variable that shadows a procedure can all send you to the wrong place. grep finds text; a tags index or a language server finds symbols, resolving the rename and the scope — which is why, for anything past a quick look, you graduate from grep to tags.

🔧 Modern vs Legacy: navigation is harder in old code, and knowing why tells you where to look. A FORTRAN 77 code (Chapter 17) has no modules, so there is no use graph to grep — the dependencies run through COMMON blocks and INCLUDE files instead, and the "module map" you reconstruct is a map of which routines touch which COMMON block. grep for the COMMON block names as you would grep for use, and grep for INCLUDE to find the shared declarations. The technique is the same — follow the connections mechanically — but the connections are made of global memory instead of checked interfaces, which is exactly the fragility Chapter 8 said modules cured.


36.5 Reading Someone Else's Fortran: Where State Lives and Where the Time Goes

Finding a routine is not the same as understanding it, and understanding someone else's scientific code comes down to two questions that the epigraph to this chapter frames perfectly. Brooks said: show me your tables, and the flowcharts become obvious. Translated to our work: understand where the data lives and how it flows, and the algorithm explains itself. So when you read an unfamiliar routine, do not trace the control flow first. Trace the data.

Where does state live? Every value a program computes lives somewhere, and in Fortran there are only a few somewheres, arranged from easiest to hardest to reason about. Best is state passed explicitly as procedure arguments and returned as results — you can see exactly what a routine reads and writes by reading its signature and its intents. Next is state bundled in a derived type (our field_t) and threaded through as one argument — still explicit, just tidier. Hardest is global state: module variables (Chapter 8) that many routines share, or, in old code, COMMON blocks — because a routine that reads a module variable takes an input that does not appear in its argument list, so you cannot know what it depends on without searching the whole module. Here is the difference, made concrete and compilable:

module kinds
  implicit none
  integer, parameter :: dp = selected_real_kind(15, 307)
end module kinds

module global_config              ! STATE HIDDEN IN A MODULE: a global input
  use kinds, only: dp
  implicit none
  real(dp) :: alpha = 0.5_dp      ! defaults; some OTHER routine may change these
  integer  :: n     = 2
contains
  subroutine set_config(a, m)
    real(dp), intent(in) :: a
    integer,  intent(in) :: m
    alpha = a;  n = m
  end subroutine set_config
end module global_config

program where_state_lives
  use kinds,         only: dp
  use global_config, only: alpha, n, set_config
  implicit none

  print '(a, f6.2)', 'global, defaults : ', via_global()    ! reads hidden state
  call set_config(0.25_dp, 8)                                ! a spooky action at a distance
  print '(a, f6.2)', 'global, after set: ', via_global()    ! same call, different answer!
  print '(a, f6.2)', 'explicit argument: ', via_arg(0.25_dp, 8)  ! nothing hidden

contains

  real(dp) function via_global()             ! its inputs are INVISIBLE in the signature
    via_global = alpha * real(n, dp)
  end function via_global

  real(dp) function via_arg(a, m)            ! its inputs are RIGHT THERE
    real(dp), intent(in) :: a
    integer,  intent(in) :: m
    via_arg = a * real(m, dp)
  end function via_arg

end program where_state_lives
$ gfortran -std=f2018 -Wall -O2 example-02-where-state-lives.f90 -o state && ./state
global, defaults :   1.00
global, after set:   2.00
explicit argument:   2.00

Read the output and feel the hazard. via_global() is called twice with identical syntax and returns two different answers — $0.5 \times 2 = 1.00$, then $0.25 \times 8 = 2.00$ — because between the calls, some other line reached into the module and changed the state it silently depends on. Nothing at the call site tells you this can happen. via_arg(0.25, 8) returns $2.00$ and always will, for those arguments, forever, because its entire input is visible in the call. This is why reading a routine that leans on global state is slow: to know what it computes, you must find everyone who might have written the state it reads. When you meet such a routine, your first move is to grep for the module variable's name and see who assigns to it — that search is the price the code makes you pay for hiding the input.

💡 Intuition: to understand a routine, ask "what does it read, and what does it write?" A routine whose answer is entirely in its argument list and intents is one you can understand in isolation. A routine that reads module variables has hidden inputs; a routine that writes them has hidden outputs — "spooky action at a distance," where calling this routine changes the answer of a distant one. This is not a reason to ban module state (a shared configuration or a timer legitimately wants it), but it is the reason to keep it rare, private, and mutated through a small, obvious set of procedures — precisely the discipline of Chapter 8.

Follow the data flow. With "where state lives" answered, understanding the algorithm becomes following one piece of data through the code. In our solver: the field is born in the driver's setup, flows into step each iteration (where the physics reads it and writes it back), and flows out to write_field. Trace that one object — grep for field, read each site — and you have traced the computation, because in a numerical code the data flow is the algorithm. The control flow (the loops, the conditionals) is scaffolding around the movement of the field, and it makes sense only once you see what is moving.

Where does the time go? The last question you will ask of someone else's code — usually because you have been asked to make it faster — is where it spends its time, and here the crucial discipline from Chapter 28 is: measure, do not guess. Human intuition about hot spots is famously wrong; the loop you think is expensive is often trivial, and the time is buried in a routine you would not have suspected. But you can form a hypothesis to test, and the structure of the code guides it. In almost every explicit PDE solver, the time goes where the arithmetic is densest and most repeated: the innermost stencil loop, evaluated over every interior cell, every timestep. For our heat solver that is the Laplacian inside step — which is why Chapter 28 profiled exactly there, and why Chapter 29 optimized exactly there. The 80/20 rule holds with unusual force in scientific computing: a tiny fraction of the code — one or two hot loops — accounts for the overwhelming majority of the runtime, and finding that fraction is most of the optimization job.

⚡ Performance Note — read the code, then profile, then optimize, in that order. The temptation, handed a slow code, is to start optimizing the first expensive-looking loop you see. Resist it exactly as §36.4 said to resist reading top to bottom. First navigate (find the solver and its inner loop); then profile (Chapter 28) to confirm where the time actually goes; only then optimize (Chapter 29), and re-measure. The architecture skills of this chapter are what make step one fast: a code you can navigate is a code you can profile, and a code you can profile is a code you can make fast without breaking. Performance is not accidental, and neither is finding it — it starts with reading the code well.

📜 From History — why old scientific codes are hard to read, and why it is not their authors' fault. Much of the world's scientific Fortran was written by scientists, not software engineers, under pressure to get a result, on machines and in a language (FORTRAN 77) that offered no modules, no derived types, and no way to pass state cleanly — only COMMON blocks and global memory. The result is code where state lives everywhere and flows invisibly, which is genuinely hard to read. But that same code is often validated against decades of experiment — it is trusted precisely because it has been right for thirty years. This is the theme worth carrying out of Part IV and into your career: legacy code is not a burden, it is an inheritance. The reading skills of this chapter — find where state lives, follow the data, measure before you change — are what let you improve the engineering of such a code without endangering the science encoded in it. You are not there to judge how it was written; you are there to understand it, and to leave it better than you found it.

🔄 Check Your Understanding. 1. Reading a routine, you find it uses a value gravity that appears nowhere in its argument list. Where is gravity most likely defined, and what is the one search you must do to know what value it holds when this routine runs? 2. Why does a routine whose entire input and output is in its argument list and intents take less time to understand than one that reads and writes module variables? 3. You are asked to speed up an unfamiliar explicit finite-difference code. Before touching anything, what are the first two things you do, and in what order?

Answers (1) It is almost certainly a module variable (global state) — a shared configuration or physical constant. To know its value at this routine's call you must grep for gravity and find every place that assigns to it, because any of them could have set it. (2) Its inputs and outputs are visible: you read the signature and know exactly what it depends on and changes, so you can understand it in isolation. The module-variable version has hidden inputs (values set elsewhere) and hidden outputs (values other routines later read), so understanding it requires searching the whole module — you cannot reason about it locally. (3) First navigate — find the entry point, the solver, and its inner stencil loop (the likely hot spot). Then profile to confirm where the time actually goes. Only after measuring do you optimize. Navigate, then measure, then change.


Project Checkpoint

Your solver has grown, chapter by chapter, into a real set of modules — kinds, heat_types, heat_solver, heat_io, timers, and the heat driver — but they have been sitting in one flat directory, compiled by a lengthening gfortran command line. This checkpoint does to your project exactly what this chapter is about: we reorganize it into a real scientific-code layout, give it a build system, and draw its dependency graph, so that the capstone (Chapter 38) inherits software, not a pile of files.

The source tree. Lay the modules out by the roles of §36.2 — utilities at the bottom, the core types and solver in the middle, I/O beside them, the driver on top — in the src/ + app/ + test/ shape fpm expects:

heat-solver/
├── fpm.toml                     the build manifest
├── README.md                    what it solves; how to build and run
├── src/                         THE LIBRARY
│   ├── kinds.f90                    UTILITY: the dp precision kind
│   ├── timers.f90                   UTILITY: tic/toc around system_clock
│   ├── heat_types.f90               CORE:    the field_t derived type
│   ├── heat_solver.f90              SOLVER:  laplacian (physics) + step + stable_dt
│   └── heat_io.f90                  I/O:     read_config, write_field
├── app/
│   └── main.f90                     DRIVER:  program heat — setup, time loop, output
└── test/
    └── test_step.f90                a first regression check (grows in Chapter 37)

The module map. The dependency graph is the layering made explicit; read each arrow as "uses," and note that it is a tree — the shape that guarantees a clean compile order and lets you understand each module knowing only the ones below it:

                    program heat  (app/main.f90)      ← the driver
                   /     |      |        \
                  /      |      |         \
        heat_solver   heat_io  timers      \
          step,       read_,   tic,         \
          stable_dt   write_   toc           \
              \        |        |            /
               \       |        |           /
              heat_types (field_t)         /
                     \                    /
                      \                  /
                          kinds  (dp)   ← the foundation everything rests on

kinds depends on nothing; timers and heat_types rest on it; heat_solver and heat_io rest on heat_types; the driver sits above all of them. fpm reads these use statements and derives this order on its own — the whole point of §36.3 — so building is now three words instead of a fragile file list:

$ fpm build && fpm run
reorganized heat solver (fpm src/ layout), 5x5 grid, dt = 0.20
after 2 steps:
  100.00  100.00  100.00  100.00  100.00
    0.00   28.00   32.00   28.00    0.00
    0.00    4.00    4.00    4.00    0.00
    0.00    0.00    0.00    0.00    0.00
    0.00    0.00    0.00    0.00    0.00
max temperature:   100.00
elapsed (2 steps): 0.000002 s   [machine-dependent]
wrote field to heat_0002.txt

Nothing about the physics changed — this is the identical 5×5, two-step result you hand-computed in Chapter 24, the warmth spreading inward from the hot top edge, with the held edge still at its maximum of $100$. What changed is the architecture: the code now has a source tree that mirrors its dependency graph, a build system that tracks compile order for you, and a test/ directory waiting for the tests of Chapter 37. The complete, self-contained program — every module bundled into one file so it compiles on its own, with the hand-computed output — is code/project-checkpoint.f90; in the real project each module is its own file under src/, exactly as the tree shows. A maintainer opening this project cold would find the README, read the thin driver in app/main.f90, glance at the module map, and be productive in minutes. That is the difference between code that runs and code that is software, and your solver is now the latter.

🔗 Connection. This is the layout the capstone presents as a finished piece of research. When Chapter 38 asks you to write your solver up "as a paper would," this source tree, this build file, and this module map are the Methods section's software half — the reproducible, navigable, testable artifact behind the figures. You are not tidying for its own sake; you are building the thing you will hand to a reviewer.


Summary

This chapter was the map of Part IX: how large scientific Fortran codes are organized, and how to find your way around one you did not write.

Idea The short version
The source tree is the dependency graph Districts by responsibility (util/, core/, physics/, io/, driver/), layered bottom-to-top. Read it as a topological sort: kinds first, main last.
Five architectural roles Driver (orchestrates, no science), solver (the numerical engine), physics (one process each, pure), I/O (all outside-world contact), utility (kinds/constants/timers, at the bottom).
Build systems Make (manual, universal), CMake (big multi-language codes), Meson (modern, clean), fpm (Fortran-native, convention over configuration — scans use, derives compile order).
Build configuration Compiler + flags + options + library versions. Two builds of one source can give two numbers — record it, or the result is not reproducible.
Navigate, don't read README + build file first; find the program (entry point); build the module map by grepping use; then follow a name with grep and tags, pulling in only what your task needs.
Where state lives Argument-passed state is local and readable; module-variable/COMMON global state has hidden inputs and outputs. To read a routine, find what it reads and writes first.
Where the time goes The 80/20 rule bites hard: the inner stencil loop is almost always the hot spot. But measure (Ch. 28) — hypothesis from structure, confirmation from a profiler.

The two things worth remembering. First, you navigate a large code by its architecture, not by reading it — hold the map (the five roles, the module graph) in your head and query the rest with grep and tags. Second, to understand a routine, find where its state lives and follow the data — argument-passed state you can read locally; global state makes you search the whole module, which is exactly why clean codes keep it rare.

Spaced Review

Retrieval practice on the two chapters this one builds directly on — modules (Chapter 8), the architecture we are now seeing at scale, and error handling (Chapter 13), the robustness a maintainable code needs. Answer before peeking.

  1. (Ch. 8) A large code's src/ tree has util/ at the bottom and driver/ at the top. Restate the two rules from Chapter 8's "designing a module hierarchy" that this layout physically encodes, and say what a sideways dependency between two physics/ modules would signal.

    Answer The layout encodes **one responsibility per module** (each file/district does one job) and **depend downward, never sideways or up** (each layer `use`s only layers below it). A sideways dependency between two peer physics modules signals a boundary in the wrong place — a shared need that belongs *lower* (in a utility or core module both can use) — and, if it became mutual, would be an illegal circular dependency the compiler rejects.

  2. (Ch. 8) You clone a code and want its "true table of contents." Why is grepping every use statement to build the module map more reliable than trusting the directory names, and what does a .mod file have to do with why the map cannot lie?

    Answer Directory names are a convention an author can violate (files misfiled, modules mis-named); the `use` graph is enforced by the compiler, because a module cannot be compiled until the `.mod` files of everything it `use`s already exist. The dependency graph is therefore *real* — it determines compile order and is checked on every build — whereas the folder layout is only a hopefully-faithful picture of it.

  3. (Ch. 13) Reading an unfamiliar solver, you find a subroutine that allocates a work array with no stat= and no argument validation. Name the two Chapter 13 habits it is missing, and say what each would turn a silent failure into.

    Answer It is missing (a) a **guarded allocation** — `allocate(a(n), stat=s, errmsg=msg)` with a check — which turns an out-of-memory abort into a reported, recoverable failure with a message; and (b) **input validation / preconditions** (`validate_config`-style checks, or an `assert`) — which turns a nonsensical size like `n <= 0` into an immediate, located `error stop` instead of an out-of-bounds crash or garbage far downstream. Both convert a silent, delayed failure into a loud, early, diagnosable one.

  4. (Ch. 13) Global module state, §36.5 warned, has hidden inputs and outputs. Connect this to Chapter 13's "implicit save" trap: why is a module variable's persistence between calls both the feature that makes global state useful and the property that makes it hard to reason about?

    Answer A module variable is implicitly `save`d — it persists for the whole run and keeps its value between calls — which is *why* it can serve as shared configuration or an accumulating timer (the feature). But that same persistence means its value at any call depends on the entire history of who wrote it, so a routine reading it has an input you cannot see or reconstruct locally (the hazard). Chapter 13's trap — an initializer in a declaration silently confers `save` — is the same property biting by accident; §36.5's global state is it used on purpose, and both demand the same care: keep it rare, `private`, and obviously mutated.

What's Next

You can now walk into a hundred thousand lines of unfamiliar Fortran and find your way — read its tree, name its roles, map its modules, follow its data. And you have reshaped your own solver into a real package with a source tree and a build system. But a navigable code is not yet a trustworthy one. How do you know it still computes the right answer after you have refactored it? How does anyone else know, without rerunning your entire analysis? Chapter 37 answers that: testing the solver with pFUnit unit tests and regression tests against the analytical solution, documenting it with FORD, wiring up continuous integration so every change is checked automatically, and pinning down reproducibility so a result can be regenerated years later. The test/ directory you just created is where it begins. Navigation got you into the code; the next chapter is how you change it without breaking the science.