Case Study 2: From a Translated Kernel to a Designed Library

"Translating the code was the assignment. Designing what it should have been all along was the opportunity."

Executive Summary

The Project Checkpoint modernized the FORTRAN 77 PLATE kernel line by line: COMMON became a module, the statement function became a pure function, the GOTO loop became do/exit. That is a faithful translation, and it is where a cautious modernization stops. This case study goes one tier further and asks the question a good engineer asks once the translation is safe: now that the numerics are locked down and tested, what should this code have been? We package the modernized PLATE as a small library built around a field_t derived type — a designed interface, not just a de-GOTO-ed one — and we design it deliberately so that the two big changes the rest of the book will make (the real finite-difference stencil in Chapter 24, and OpenMP in Chapter 33) drop in without touching the interface. Where Case Study 1 ported existing code, this one designs the target the port should aim at.

Skills applied: COMMON → a derived type rather than loose module variables (§19.1, and Chapter 9); assumed-shape and intent throughout (§19.3); the GOTO loop as do/exit (§19.2); designing a stable interface around a replaceable body (Chapter 6); and the numerical-equivalence discipline of Chapter 18.

Background

PLATE shared its state through one COMMON block — /GRID/, holding the temperature array T and the working size N. The literal §19.1 translation makes that into module variables, which is a real improvement (one authoritative declaration) but keeps the shape of the old design: a pile of global state that procedures reach into. The moment you have more than one plate — a unit test on a $4\times4$ beside a production run on a $200\times200$, or two regions of a decomposed domain — global state is exactly the wrong model, because there is only one of it.

The design target is instead a field_t: a single object that carries a plate's dimensions and its temperature array together, so a procedure receives a plate rather than reaching for the plate. This is the same move the running heat-solver project makes at Chapter 9; here we arrive at it from the legacy side, as the natural destination of the COMMON → module translation once you let yourself design rather than merely translate.

Phase 1 — Design the Type and the Interface First

Following the interface-first discipline of Chapter 6, decide the shape of the library before writing a solver body. The type bundles what /GRID/ held as loose members:

module plate_types
  use, intrinsic :: iso_fortran_env, only: dp => real64
  implicit none
  private
  public :: field_t
  type :: field_t
     integer :: nx = 0, ny = 0
     real(dp), allocatable :: t(:,:)     ! sized at run time (was T(NMAX,NMAX))
  end type field_t
end module plate_types

The public procedures form a minimal, honest API — create a field, set its edges, relax it, probe an interior cell — each taking a field_t and declaring its intent:

Procedure Signature Intent of the field Role
init_field (fld, nx, ny) intent(out) allocate and zero a plate of the given size
set_edges (fld, t_hot, t_cold) intent(inout) impose the Dirichlet boundary
relax (fld, tol, max_iter) intent(inout) Jacobi-relax the interior to steady state
hot_cell (fld) result(c) intent(in) (pure) report the interior cell next to the hot edge

Committing to these four signatures now is the whole game: the body of relax will be rewritten twice more in this book, but if the signature holds, no caller ever changes.

Phase 2 — Build the Library

The bodies are the modernized PLATE, re-housed against the type. Note that relax's inner update is exactly the four-neighbour average we translated from the statement function — isolated on its own line so a better stencil can replace it later without disturbing the loop around it:

module plate_solver
  use, intrinsic :: iso_fortran_env, only: dp => real64
  use plate_types, only: field_t
  implicit none
  private
  public :: init_field, set_edges, relax, hot_cell

contains

  subroutine init_field(fld, nx, ny)
    type(field_t), intent(out) :: fld       ! out: allocatable component starts deallocated
    integer,       intent(in)  :: nx, ny
    fld%nx = nx
    fld%ny = ny
    allocate(fld%t(nx, ny))
    fld%t = 0.0_dp
  end subroutine init_field

  subroutine set_edges(fld, t_hot, t_cold)
    type(field_t), intent(inout) :: fld
    real(dp),      intent(in)    :: t_hot, t_cold
    fld%t = t_cold                 ! interior + three cold edges
    fld%t(1, :) = t_hot            ! top edge (row 1) hot
  end subroutine set_edges

  subroutine relax(fld, tol, max_iter)
    type(field_t), intent(inout) :: fld
    real(dp),      intent(in)    :: tol
    integer,       intent(in)    :: max_iter
    real(dp), allocatable :: told(:,:)
    real(dp) :: diff
    integer  :: i, j, iter
    allocate(told(fld%nx, fld%ny))
    iter = 0
    do
       iter = iter + 1
       told = fld%t
       diff = 0.0_dp
       do j = 2, fld%ny - 1
          do i = 2, fld%nx - 1
             fld%t(i,j) = 0.25_dp * (told(i-1,j) + told(i+1,j) &   ! <- the replaceable line
                                   + told(i,j-1) + told(i,j+1))
             diff = max(diff, abs(fld%t(i,j) - told(i,j)))
          end do
       end do
       if (diff <= tol .or. iter >= max_iter) exit
    end do
  end subroutine relax

  pure function hot_cell(fld) result(c)
    type(field_t), intent(in) :: fld
    real(dp) :: c
    c = fld%t(2, 2)                 ! an interior cell adjacent to the hot edge (row 1)
  end function hot_cell

end module plate_solver

Phase 3 — Drive It and Check the Number

The driver reads like a description of the physics, which is the payoff of a designed interface:

program plate_lib_demo
  use plate_types,  only: field_t
  use plate_solver, only: init_field, set_edges, relax, hot_cell
  use, intrinsic :: iso_fortran_env, only: dp => real64
  implicit none
  type(field_t) :: plate

  call init_field(plate, 4, 4)
  call set_edges(plate, 100.0_dp, 0.0_dp)
  call relax(plate, 1.0e-6_dp, 1000)
  print '(a, f8.2)', 'cell next to hot edge = ', hot_cell(plate)
end program plate_lib_demo
$ gfortran -std=f2018 -Wall -O2 plate_types.f90 plate_solver.f90 plate_lib_demo.f90 -o plate && ./plate
cell next to hot edge =    37.50

The probe reads 37.50, and — as in the Project Checkpoint — that is a theorem, not a measurement. On the $4\times4$ plate the two interior cells next to the hot edge equal $a$ and the two next to the cold edge equal $b$; steady state makes each the average of its four neighbours, giving $3a - b = 100$ and $a = 3b$, hence $8b = 100 \Rightarrow b = 12.5$, $a = 37.5$. So hot_cell returns 37.50, the value Chapter 18 established as the reference and proved by hand. The design changed; the science did not — which is exactly the invariant Chapter 18's regression tests are meant to protect.

Phase 4 — Confirm the Interface Survives the Future

The reason to design rather than merely translate is that a designed interface absorbs change. Walk the two big future edits and confirm each touches only a body:

  • Chapter 24 (the real stencil). The genuine finite-difference update — a proper Laplacian with $dx$, $dy$, a diffusivity, and a CFL-limited timestep — replaces the single marked line inside relax. The field_t, the four public signatures, and every caller are untouched. The four-neighbour average was always a placeholder for this exact substitution.
  • Chapter 33 (OpenMP). The interior double loop gets a !$omp parallel do directive (or becomes a do concurrent). Because each cell's update reads only the told snapshot and writes only its own fld%t(i,j), the iterations are independent and the parallelization is safe by construction — a property we secured by keeping the update a pure, snapshot-based computation, not by anything OpenMP-specific.

Neither edit changes a line the caller can see. That is what "design the interface first" buys, and it is why the extra effort over the literal translation pays for itself the first time the code has to grow.

The design lesson, stated generally: the literal dictionary translation (§19.1's COMMON → module) makes legacy code safe; promoting the result to a designed type-plus-interface makes it extensible. Do the translation first, under the protection of a regression test, and only then — with the numerics pinned — reshape it into what it should have been. Reversing the order (redesigning while you still are not sure the numbers match) is how modernizations quietly break validated science.

Phase 5 — Cost, and When to Stop

Be honest about the trade. The field_t library is more code than the loose-module translation: a type definition, an init_field you did not need when the arrays were module variables, and a little fld% ceremony at every use. For a thirty-line one-shot script that ceremony is not worth it, and the loose-module translation is the right stopping point. The library earns its keep when the code will live — when there will be more than one plate, when the stencil and the parallel back-end are coming, when other people will call it. PLATE is destined for all of those (it is the book's legacy anchor), so the design is warranted. Recognizing which of your legacy kernels deserve this second phase, and which should stop at the safe literal translation, is itself a senior skill: not every old routine needs to become a library, but the load-bearing ones do.

Discussion Questions

  1. init_field declares the field intent(out), which deallocates the t component on entry. What bug would intent(inout) risk here, and what would intent(out) cost if you called init_field on an already-populated field you meant to keep?
  2. We kept the four public procedures as free procedures in a module. An alternative is type-bound procedures, so callers write call plate%relax(...) (Chapter 9). What does the type-bound style add, and does it change how well the interface survives the Chapter 24 and 33 edits?
  3. Phase 5 argues some legacy kernels should stop at the literal translation. Give two concrete signals, in a codebase you have to modernize, that a routine does deserve promotion to a designed library — and two that it does not.

Your Turn: Extensions

  • Option A. Add a total_energy(fld) result(e) pure function that returns sum(fld%t), and use it as a second regression check: energy should be identical between the legacy and library versions on the same grid.
  • Option B. Give relax an optional, intent(out) :: iters argument reporting the iteration count, so the caller can see convergence without the routine printing anything. Guard it with present (Chapter 6).
  • Option C. Replace the interior double loop with a single whole-array update of the interior section (fld%t(2:nx-1, 2:ny-1) = 0.25_dp*( ... shifted sections ... )) and confirm hot_cell is still 37.50. Which form will Chapter 33 parallelize more obviously — the explicit loop or the array section?

Key Takeaways

  • Translate first, design second. The literal COMMON → module move makes the code safe under a regression test; only then reshape it into a field_t-plus-interface that makes it extensible.
  • A derived type is where COMMON wants to go. Bundling the grid into one object turns "reach for the global plate" into "receive a plate," which is what lets you have more than one.
  • Design the interface, treat the body as replaceable. Four stable signatures let the real stencil (Ch. 24) and OpenMP (Ch. 33) drop in without changing a single caller.
  • Not every kernel earns a library. Stop at the safe literal translation for throwaway code; invest the extra design in the load-bearing routines that will grow — and know how to tell them apart.