Case Study 2: Architecting a Growing Simulation

"A module boundary drawn well is invisible for years; drawn badly it is a tax you pay on every commit."

Executive Summary

Where the first case study read a failure, this one asks you to design so the failure never arises. You are the architect of a heat-simulation code that everyone agrees will keep growing — more physics, more I/O formats, timers, tests, eventually parallel back-ends — and the decision in front of you is where to draw the module boundaries. Draw them well and the code compiles fast, tests in pieces, and absorbs new features without disturbing old ones. Draw them badly and you get recompilation cascades, a circular dependency that will not build, and a heat_solver that has quietly grown tentacles into every other file. We will inventory the responsibilities, lay them into layers, catch and kill a circular dependency before it is ever compiled, apply a submodule exactly where it earns its keep, and finish with a compilable skeleton and the compile order that falls out of the design.

Skills applied: one-responsibility module design and layered dependencies (§8.6); resolving circular dependencies (§8.5); interface/implementation separation with submodules for build speed (§8.3); private-state-behind-an-interface (§8.2); deriving compile order from a dependency graph (§8.5). The layout here is the one you will recognize in real codes in Chapter 36.

Background

The heat solver from this chapter's Project Checkpoint works, but it is about to grow. The near-term roadmap adds: a namelist configuration reader (Chapter 7), a field_t derived type (Chapter 9), timers around the update (Chapter 28), VTK output (Chapter 26), and eventually OpenMP/MPI variants of the update (Part VIII). Your job now is not to write all of that — it is to choose a module structure that will hold all of it without being rebuilt from scratch each time. The right time to design the hierarchy is now, while it is small enough to change cheaply.

Phase 1 — Inventory the Responsibilities

Design begins with a list of jobs, each nameable in a short phrase. If you cannot name a module's job crisply, it is doing too much. Here is the inventory:

Responsibility Proposed module Why it is one job
The precision kind and physical constants kinds one authoritative dp, used everywhere
Run parameters (grid size, steps, dt) sim_config the "what to run" knobs, in one place
The numerical update heat_solver the physics, and nothing else
Reading and writing data heat_io all persistence, isolated from physics
Timing the run timers measurement, orthogonal to everything
Orchestration (setup, time loop, output cadence) program heat the conductor, owns no algorithm

Six responsibilities, six units. Note what is not combined: the physics (heat_solver) is kept separate from the I/O (heat_io) even though both touch the field, because "how you compute the next state" and "how you write it to disk" change for completely different reasons and at completely different times. That separation is the single most important boundary in the whole design, and Phase 3 will show why.

Phase 2 — Lay the Modules into Layers

Now assign each module a layer, with the rule from §8.6: depend downward only. Foundations at the bottom, orchestration at the top, every arrow pointing down.

   Layer 3 (top)     program heat            ← owns the time loop; no algorithm
                    /   |    |    \
   Layer 2      heat_solver  heat_io  timers  ← the "what": physics, I/O, timing
                    \   |    |    /
   Layer 1        sim_config  |   /            ← the run's parameters
                        \     |  /
   Layer 0 (bottom)        kinds               ← dp: the shared foundation

Read every arrow as "uses." kinds uses nothing. sim_config uses kinds. The three Layer-2 modules use kinds (and heat_solver/heat_io use sim_config for the dimensions). The driver uses everything below it. The graph is acyclic and shallow — the two properties that make a hierarchy buildable and comprehensible. If any arrow pointed sideways (peer to peer) or upward, that would be the alarm bell we investigate next.

Phase 3 — Catch and Kill a Circular Dependency

Here is the design mistake that real teams make, and it is worth watching happen. A developer wants mid-step debug output, so they make heat_solver use heat_io to call a dump routine inside step. Later, another developer wants heat_io to re-normalize a field before writing it, so they make heat_io use heat_solver. Now:

   heat_solver ──uses──▶ heat_io
        ▲                   │
        └────────uses───────┘        ← a cycle: neither can compile first

This will not build. heat_solver needs heat_io.mod to compile, heat_io needs heat_solver.mod to compile, and neither .mod can be created first (§8.5). The compiler rejects it — which, as §8.5 argued, is a feature: the tool caught a design smell before it could rot.

The fix is not a mechanical trick; it is to notice that the cycle means a responsibility is misplaced. Two clean resolutions, in order of preference:

  1. Move the shared need down a layer. Both routines really want the same low-level operation (a field statistic, a normalization). Put that operation in a lower module — say, field_ops in Layer 1 — that both heat_solver and heat_io use. The cycle vanishes because both now depend downward on a common foundation instead of sideways on each other. This is almost always the right answer, and it usually reveals a module you should have had anyway.
  2. Push the I/O out of the algorithm entirely. Better still, question whether step should do I/O at all. It should not: writing to disk is the driver's job, done between steps, not the solver's job done inside one. Keep heat_solver free of any I/O, let the driver call report after each step, and the coupling that created the cycle never exists. A step that touches no files is also the one you can later hand to OpenMP or MPI unchanged.

We take resolution (2): the solver computes, the driver orchestrates, and heat_solver never uses heat_io. (Had the coupling been genuinely irreducible, §8.3's submodule is the escape hatch — a submodule (heat_io) may use heat_solver without forming a cycle in the module graph. Reach for it only when a downward refactor is truly impossible.)

Phase 4 — Apply a Submodule Where It Pays

heat_solver is the module that will change most often — every numerical improvement, every parallel back-end, touches its body. But its interface, step(field, alpha, dt), is frozen for the rest of the book. That is the exact situation §8.3 was built for: put the stable interface in the module and the volatile body in a submodule, so that recompiling the physics does not recompile the driver, the I/O, or anything else that merely calls step.

! heat_solver.f90 — the stable interface (rarely changes)
module heat_solver
  use kinds, only: dp
  implicit none
  private
  public :: step
  interface
    module subroutine step(field, alpha, dt)
      real(dp), intent(inout) :: field(:,:)   ! dp by host association
      real(dp), intent(in)    :: alpha, dt
    end subroutine step
  end interface
end module heat_solver
! heat_solver_impl.f90 — the volatile body (changes every optimization)
submodule (heat_solver) heat_solver_impl
  implicit none
contains
  module subroutine step(field, alpha, dt)
    real(dp), intent(inout) :: field(:,:)
    real(dp), intent(in)    :: alpha, dt
    real(dp), allocatable   :: old(:,:)
    real(dp) :: lap
    integer  :: i, j, nx, ny
    nx = size(field,1);  ny = size(field,2)
    allocate(old(nx,ny));  old = field
    do j = 2, ny-1
       do i = 2, nx-1
          lap = old(i-1,j)+old(i+1,j)+old(i,j-1)+old(i,j+1) - 4.0_dp*old(i,j)
          field(i,j) = old(i,j) + alpha*dt*lap
       end do
    end do
  end subroutine step
end submodule heat_solver_impl

With the rest of the skeleton (kinds, a minimal sim_config, heat_io, and the driver):

module sim_config
  implicit none
  private
  integer :: nx = 0, ny = 0, n_steps = 0
  public :: configure, get_dims
contains
  subroutine configure(nx_in, ny_in, ns_in)
    integer, intent(in) :: nx_in, ny_in, ns_in
    nx = nx_in;  ny = ny_in;  n_steps = ns_in
  end subroutine configure
  subroutine get_dims(nx_out, ny_out)
    integer, intent(out) :: nx_out, ny_out
    nx_out = nx;  ny_out = ny
  end subroutine get_dims
end module sim_config

module heat_io
  use kinds, only: dp
  implicit none
  private
  public :: report
contains
  subroutine report(step_no, field)
    integer,  intent(in) :: step_no
    real(dp), intent(in) :: field(:,:)
    print '(a, i0, a, f8.2)', 'step ', step_no, ': max = ', maxval(field)
  end subroutine report
end module heat_io

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

program heat
  use kinds,       only: dp
  use sim_config,  only: configure, get_dims
  use heat_solver, only: step
  use heat_io,     only: report
  implicit none
  real(dp), allocatable :: field(:,:)
  integer :: nx, ny
  call configure(4, 4, 1)
  call get_dims(nx, ny)
  allocate(field(nx, ny));  field = 0.0_dp;  field(1,:) = 100.0_dp
  call step(field, 0.1_dp, 1.0_dp)     ! solver computes ...
  call report(1, field)                ! ... driver orchestrates the I/O
end program heat

The driver is the only unit that touches both the solver and the I/O, and it touches each through a small, checked interface. Compile with the modules in dependency order (the submodule right after its parent):

$ gfortran -std=f2018 -Wall kinds.f90 sim_config.f90 heat_solver.f90 \
      heat_solver_impl.f90 heat_io.f90 heat.f90 -o heat && ./heat
step 1: max =   100.00

Verify by hand: the $4\times4$ field starts with a $100$-degree top edge and zeros elsewhere; one step raises the two interior cells beside the hot edge to $10.00$ and leaves the edge at $100.00$, so maxval(field) = 100.00. The number is trivial on purpose — the case study is about the structure that produced it, and the structure is now one a growing code can live in.

Phase 5 — The Compile Order, and Why the Design Earns It

The compile order is not something you invent; it falls out of the layer diagram, read bottom to top:

Order Unit Depends on
1 kinds nothing
2 sim_config (nothing here; kinds if it used dp)
3 heat_solver (interface) kinds
4 heat_solver_impl (submodule) heat_solver (its .smod)
5 heat_io kinds
6 program heat all of the above

Because the graph is acyclic, such an order always exists (it is a topological sort); because it is shallow, the order is obvious and the build is fast. And the submodule split means that tomorrow, when you replace the placeholder update with Chapter 24's real five-point stencil, only unit 4 recompiles — the interface (unit 3), the I/O, the config, and the driver are untouched, and on a large code that is the difference between a fast edit-build-test loop and a slow one. The design paid for itself the first time the physics changed, which in a research code is roughly every day.

Discussion Questions

  1. Phase 3 offered two fixes for the circular dependency. Give a concrete example of a coupling where fix (1) — a shared lower module — is clearly better than fix (2), and one where the reverse is true.
  2. sim_config stores its parameters in private module variables reached through configure/get_dims. Contrast this with a public data module (like the grid module in Case Study 1). When is each the right call?
  3. The driver is the only unit that knows about both heat_solver and heat_io. Why is concentrating that knowledge in the top layer a good thing, and what would you lose if heat_solver called report itself?

Your Turn: Extensions

  • Option A. Add the timers module to the skeleton: give it tic()/toc() wrapping system_clock, place it in the correct layer, and have the driver time the step call. Which existing files must change, and which must not? State the new compile order. (This previews Chapter 28.)
  • Option B. Suppose heat_io must grow both a text writer and a VTK writer that share a filename helper. Design the sub-structure: one module with a private helper, or a small module plus a submodule? Justify your choice in terms of build cost and clarity.
  • Option C. Draw the dependency graph your design would have if you (wrongly) merged heat_solver and heat_io into one physics_io module. List three concrete problems that merge would cause as the code grows, tying each to a principle from §8.6.

Key Takeaways

  • Design the hierarchy from a list of one-phrase responsibilities; when you cannot name a module's job, it is doing too much. Keep the physics and the I/O in separate modules even though both touch the field — they change for different reasons.
  • Depend downward only. An acyclic, shallow graph is buildable and comprehensible; a sideways or upward arrow is a design smell, and a circular dependency is one the compiler will (usefully) refuse.
  • Kill a cycle by moving the shared need down to a common module, or by pushing a misplaced responsibility (like I/O inside step) out to the layer that owns it. Submodules are the escape hatch only when a refactor genuinely cannot break the cycle.
  • Put a stable interface in the module and a volatile body in a submodule, so the code that changes most costs the least to rebuild. The compile order then falls out of the layer diagram for free.