Case Study 2: A Reproducible Benchmark for the Heat Solver
"If you can not measure it, you can not improve it." — commonly attributed to Lord Kelvin (William Thomson)
Executive Summary
Chapter 29 is about to make the heat solver's stencil faster, and Chapter 38 will present the whole project
as a paper with a performance section. Both need a single, defensible number: the solver's serial baseline
per-step time, measured so carefully that when you later claim "the optimized version is $2.4\times$
faster," a skeptical reviewer cannot wave it away. A lone toc() reading cannot carry that weight — it is
one sample from a noisy distribution, taken on a machine that was doing other things.
This case study builds that measurement instrument. You will design a benchmark harness around the
project's timers module — warm-up, repetition, a working-copy reset between runs, a robust statistic with
a reported spread, and a guard against the compiler deleting the very loop you are timing — and then use
cachegrind to diagnose why the stencil is slow, handing Chapter 29 a precise target: a memory-bound loop
with a bad-loop-order cache signature. The deliverable is not a faster solver (that is next chapter); it is a
trustworthy, reproducible baseline and a diagnosis, which is the only honest foundation for optimizing.
Skills applied
- Building a warm-up / repeat / robust-statistic harness on the
timersmodule (§28.1, §28.5). - Choosing and reporting the minimum, median, and spread of repeated timings (§28.5).
- Defeating dead-code elimination so the benchmark measures real work (§28.5).
- Diagnosing a memory-bound kernel with
valgrind --tool=cachegrindandcg_annotate(§28.3, §28.4). - Recording the environment (compiler, flags, machine) for reproducibility (§28.5; forward to Ch. 30, 37).
Background
The solver's time-stepping loop calls step (the frozen Chapter 6 signature) n_steps times; step's cost
is dominated by the five-point stencil inside laplacian (Case Study 1's discipline would have you confirm
that with gprof first — and §28.2's representative profile does: ~78% in laplacian). The figure of merit is
seconds per step. The naïve approach — tic; loop; toc; divide — produces a number, but not a
defensible one. We will fix that in five phases.
Phase 1 — Warm up, repeat, and reset the state
A benchmark that runs the timed code once measures start-up as much as steady state. The harness warms up, then repeats, and — because each solver run mutates the field — restores a clean working copy before every repetition. That reset is the subtlety a first attempt always misses: time the same work each rep, from the same initial state.
subroutine bench_solver(u0, alpha, dt, n_steps, n_rep, times)
use timers, only: tic, toc
real(dp), intent(in) :: u0(:,:), alpha, dt
integer, intent(in) :: n_steps, n_rep
real(dp), intent(out) :: times(n_rep)
real(dp), allocatable :: u(:,:)
integer :: rep, n
! warm-up: one full untimed run to prime caches, pages, and CPU frequency
u = u0
do n = 1, n_steps
call step_raw(u, alpha, dt)
end do
! timed repetitions, each from a fresh copy of the initial field
do rep = 1, n_rep
u = u0 ! RESET: identical work every repetition
call tic()
do n = 1, n_steps
call step_raw(u, alpha, dt)
end do
times(rep) = toc()
end do
end subroutine bench_solver
Here step_raw is the interior update on a bare array (the field_t wrapper adds nothing to time); times
comes back with n_rep samples ready for statistics. The per-step time is times(rep)/n_steps, but we do
not reduce to a single number yet — that is Phase 2.
Correctness anchor. While benchmarking, keep a cheap invariant check so you never optimize a broken run: the held hot edge means
maxval(u)must stay exactly100.0(the discrete maximum principle, r ≤ 1/4). If a "faster" variant ever changes that number, it changed the physics — stop and diagnose. Correctness is not negotiable for speed.
Phase 2 — Reduce to a statistic, with its spread
Given the sample vector, report the minimum (the least-disturbed run — the truest cost of the code itself), the median (typical behaviour, robust to a single hiccup), and the spread (so the reader knows how noisy the machine was). On a known sample set the arithmetic is fully determinate:
real(dp) :: t(5) = [0.100_dp, 0.102_dp, 0.098_dp, 0.101_dp, 0.099_dp]
! sorted: 0.098, 0.099, 0.100, 0.101, 0.102
print '(a, f6.3)', 'min = ', minval(t) ! 0.098
print '(a, f6.3)', 'median = ', t_median(t) ! 0.100 (middle of five)
print '(a, f6.3)', 'range = ', maxval(t)-minval(t) ! 0.004
min = 0.098
median = 0.100
range = 0.004
A range that is a few percent of the median (here $0.004/0.100 = 4\%$) is a healthy, quiet measurement; a range comparable to the median means the machine was too busy to trust — go quiet it and re-run. Reporting the min and the median and the spread is three numbers instead of one, and it is the difference between a measurement and a guess with good posture.
Phase 3 — Defeat dead-code elimination and pin the environment
Two ways a benchmark lies about being fast. First, if the timed loop's result is never used, an optimizing
compiler may delete it — you would time nothing and celebrate. The solver is safe here because u is read
back by the maxval check, but make the dependence explicit and unpredictable so no future refactor can
strand it:
real(dp) :: sink
sink = 0.0_dp
do rep = 1, n_rep
u = u0
call tic()
do n = 1, n_steps
call step_raw(u, alpha, dt)
end do
times(rep) = toc()
sink = sink + u(2,2) ! consume a live result each rep
end do
print *, times, sink ! printing sink keeps every run's work alive
Second, the environment must be controlled and recorded. Benchmark the build you will ship (-O2 or
-O3, never -O0), on a quiet machine, ideally with a fixed CPU frequency, and change one variable at a
time. Then write down what you did, because a number without its conditions is unreproducible:
| Record this | Example |
|---|---|
| Compiler + version | gfortran 13.2 |
| Flags | -O2 -march=native (Ch. 30) |
| Grid / steps | 512×512, n_steps = 500, n_rep = 20 |
| Machine | 1 core pinned, otherwise idle |
| Statistic | min $= 0.098$ s/run; median $0.100$; range $0.004$ |
That table is the reproducibility habit of Chapter 37, applied to performance: the "works on my machine" problem is a measurement problem too.
Phase 4 — Diagnose why: cachegrind on the stencil
The baseline tells you how fast; to tell Chapter 29 what to fix, diagnose the bottleneck's cause. §28.3's arithmetic-intensity estimate already flags the stencil as memory-bound ($I \approx 0.4$ flop/byte). Cachegrind confirms it and localises it. Run it on a small grid (cachegrind simulates, so it is slow):
$ gfortran -std=f2018 -Wall -O2 -g stencil_demo.f90 -o stencil_demo
$ valgrind --tool=cachegrind ./stencil_demo
$ cg_annotate cachegrind.out.<pid>
Two representative summaries — the solver's good loop order (inner over the first, contiguous index) versus a bad order (inner over the second index) on the identical computation:
D1 miss rate LLd miss rate
good order (i inner) 6.3% 0.9%
bad order (j inner) 18.8% 9.8%
Same D refs in both — identical work — but the bad order roughly triples the D1 misses and multiplies the
last-level misses tenfold, each miss a stall of hundreds of cycles. cg_annotate pins those misses to the
stencil's load line. This is the mechanism behind Chapter 27's "10× from loop order," now visible as a
number. The diagnosis Chapter 29 receives is precise: a memory-bound stencil whose cost is cache misses, to
be fixed by loop order and blocking — not by touching the arithmetic.
cachegrind vs perf. Cachegrind's miss rates are a reproducible guide to locality, but its absolute counts model a generic cache, not your CPU. For the real time cost on this machine, cross-check with
perf stat -e cache-misses,cache-references ./solver, which reads the hardware counters. Use cachegrind for why and where; useperffor how much.
Phase 5 — The deliverable
You now hand Chapter 29 two things a single toc() could never provide: a reproducible baseline (min
0.098 s/run, median 0.100, range 0.004, with the flags and machine recorded) and a diagnosis (memory-bound
stencil, bad-order cache signature). Every optimization in the next chapter will be judged against this
baseline, re-run through this exact harness, and reported with the same three statistics. That is what makes
a performance claim survive review — in Chapter 38's paper and anywhere else.
Discussion Questions
- The harness resets
u = u0before every timed repetition. What exactly would go wrong — in both the timing and the physics — if you forgot the reset and let each run continue from the previous one's final state? - We report the minimum, the median, and the range. Construct a scenario where reporting only the minimum would mislead a reader, and one where reporting only the mean would.
- The dead-code-elimination guard consumes
u(2,2)into asink. Why is consuming a single element enough to keep the whole time-stepping loop alive, and when might it not be enough? - Cachegrind is deterministic but models a generic cache;
perfis exact but noisy. For the claim "loop reordering cut cache misses in half," which tool belongs in the paper, and why might you cite both?
Your Turn: Extensions
- Option A (build). Complete
bench_solverinto a compilable program: bundle thekinds,timers,heat_types, andheat_solvermodules fromproject-checkpoint.f90, add the statistics of Phase 2, and print a reproducibility table like Phase 3's. Verify themaxval(u) = 100.00invariant holds every run. - Option B (design). Add a
sweepmode that benchmarks the solver at grid sizes $128, 256, 512, 1024$ and prints per-step time versus grid size. Predict the scaling (per-step cost $\sim n^2$ interior points) and check whether your measured curve matches — and where cache effects bend it. - Option C (diagnose). Deliberately write the stencil in the bad loop order, benchmark both orders through your harness, and run cachegrind on each. Report the wall-time ratio and the D1-miss-rate ratio, and confirm the slow one is slow for the reason cachegrind says.
Key Takeaways
- A defensible baseline is warm-up + repetition + a robust statistic + a reported spread + a recorded
environment — not a single
toc(). Three numbers (min, median, range) and a flags/machine table. - Reset mutated state between repetitions, or you benchmark different work each time.
- Consume the result so the optimizer cannot delete the loop you are timing.
- Separate the two questions: the harness answers how fast (the baseline); cachegrind answers why (memory-bound, bad-order cache misses). Chapter 29 needs both to optimize the right thing the right way.