Chapter 28 — Key Takeaways (Profiling and Benchmarking)
A one-page reference for timing code, profiling with gprof, classifying a kernel, measuring cache behaviour, and running a benchmark you can defend — the chapter where you learn to measure before you optimize.
The one rule
Measure first. Profile before you optimize; optimize only the hot loop the profiler names; and never optimize a loop you have not proven is hot. Human intuition about where time goes is reliably wrong.
Timing: the two intrinsics
| Intrinsic | Measures | Call form |
|---|---|---|
cpu_time(t) |
CPU-seconds consumed (differences only) | real(dp) :: t0,t1; call cpu_time(t0); …; call cpu_time(t1) |
system_clock(count, count_rate, count_max) |
wall-clock ticks → seconds | see the idiom below |
The system_clock idiom — memorize it exactly:
use, intrinsic :: iso_fortran_env, only: dp => real64, int64
integer(int64) :: c0, c1, rate
call system_clock(count_rate=rate) ! READ the rate — never hard-code it
call system_clock(count=c0)
! ... work ...
call system_clock(count=c1)
wall = real(c1 - c0, dp) / real(rate, dp) ! seconds
- Use
int64counters → high-resolution clock, and acount_maxso large it will not wrap in a run. - Read
count_ratefrom the call; do not assume 1000, $10^6$, or $10^9$. - Wrap-around guard (only needed for default-integer clocks):
if (ticks < 0) ticks = ticks + count_max + 1. - Both intrinsics: only differences are meaningful — the absolute value is an arbitrary origin.
Wall vs CPU time — reading the gap
| Observation | Meaning |
|---|---|
| wall $\approx$ CPU (both large) | healthy compute-bound run — now profile it |
| wall $\gg$ CPU | I/O-bound or contended — the CPU is waiting, not computing |
| CPU $>$ wall | multithreaded — CPU-seconds summed across cores |
Profiling with gprof
$ gfortran -std=f2018 -Wall -O2 -pg -g prog.f90 -o prog # -pg on compile AND link; keep -O2
$ ./prog # writes gmon.out
$ gprof ./prog gmon.out > profile.txt
Flat profile columns: % time (self, sorted by this) · cumulative · self seconds · calls
(exact) · self ms/call · total ms/call. The % time column is usually the whole answer.
Call graph: callers above the indexed line, callees below; self vs children splits the time.
gfortran mangles names: laplacian_, MAIN__.
Classify the hot loop
Hot loop — the small region (often one innermost loop) where most of the runtime lives. Memory-bound — limited by data movement; the FP units idle, waiting. Compute-bound — limited by arithmetic; data arrives faster than it is crunched. Arithmetic intensity $I = \text{flops}/\text{byte}$; below ~1 ⇒ memory-bound.
| Kernel | $I$ (flop/byte) | Class | Fix (chapter) |
|---|---|---|---|
axpy y=y+a*x |
~0.08 | memory-bound | bandwidth-limited; little to do |
| 5-point stencil | ~0.4 | memory-bound | loop order, blocking (Ch. 29) |
| naïve matmul | low (poor reuse) | memory-bound | blocking (Ch. 29) |
tuned dgemm |
high (blocked reuse) | compute-bound | already optimal (Ch. 21) |
Diagnose which: (1) count flops/bytes; (2) add a cheap register-only flop and re-time — no slowdown ⇒ memory-bound; (3) measure the cache-miss rate (cachegrind).
Cache behaviour with cachegrind
$ valgrind --tool=cachegrind ./prog # simulated cache; deterministic; ~20-100x slower
$ cg_annotate cachegrind.out.<pid> # per-line Dr / D1mr breakdown
- Reports
D refs,D1 misses,LLd misses, and miss rates. A high D1/LL miss rate ⇒ memory-bound. - Same
D refs, different miss rate ⇒ same work, different loop order (locality). - Cachegrind = why & where (reproducible, generic cache).
perf stat -e cache-misses= how much (real HW).
Benchmarking methodology
Checklist: warm up (≥1 untimed run) · repeat (≥ ~20) · take the minimum (best case) or median
(typical) · report the spread · build -O2/-O3 · quiet machine, one variable at a time · consume
the result (or the compiler deletes the loop) · record compiler, flags, machine.
- min = least-disturbed run (noise only slows). median = robust to one outlier (unlike the mean).
- Dead-code trap: an unused result lets
-O2delete the timed loop → fake zero time. Print/accumulate it. - Reset mutated state between repetitions, or you time different work each run.
The 80/20 rule and the size of the prize
- Most runtime lives in a small fraction of code (often 90/10 or 99/1 in numerical codes).
- Speeding a fraction $p$ by factor $K$ ⇒ whole-program speedup $S = \dfrac{1}{(1-p) + p/K}$.
- Ceiling ($K\to\infty$): $S_{\max} = 1/(1-p)$. A routine at 20% caps the program at $1.25\times$.
- Knuth: "premature optimization is the root of all evil" — optimize the critical few percent, after profiling; leave the rest clear. (Amdahl's Law formalizes the ceiling — Ch. 31.)
Pitfalls
- Profiling the
-O0build — measures the wrong balance;-O2inlines helpers away. Profile what you ship. - Guessing
count_rateor using default-integer counters — wrong or negative times. - Timing an un-warmed run — measures cache/page/frequency start-up, not steady state.
- Reporting one sample — an anecdote, not a measurement.
- Optimizing before profiling — you will spend the critical 3% of effort on the wrong 3% of code.
Flags introduced
| Flag / command | Purpose |
|---|---|
-pg |
compile + link for gprof instrumentation |
-g |
keep symbols (readable names, source annotation) |
gprof exe gmon.out |
produce flat profile + call graph |
valgrind --tool=cachegrind / cg_annotate |
simulate cache; annotate misses per line |
perf stat -e cache-misses |
real hardware performance counters |
The heat-solver piece added
timers.f90 / timers — tic() / toc() result(seconds) wrapping the system_clock idiom; module
state is saved by design. Used to time the step loop and establish the serial baseline per-step time —
the figure of merit Chapters 29, 30, and 38 drive down. gprof confirms the hot loop is the stencil in
laplacian.
Numbers worth memorizing
- Elapsed = (count_end − count_start) / count_rate. Read the rate; use
int64. - $I < 1$ flop/byte ⇒ memory-bound (the stencil, and most array kernels).
- Speedup ceiling $= 1/(1-p)$ for a hot fraction $p$ — compute it before optimizing.