Chapter 29 Exercises — Optimization Techniques

These exercises turn the chapter's techniques into muscle memory: reorder a loop for column-major access, fuse two passes into one, block a matrix multiply, coax the compiler into vectorizing, and — as often as not — decide not to optimize because the profiler says there is nothing to win. The through-line is the chapter's iron rule: an optimization must never change the answer. Several problems ask you to prove that it did not.

The difficulty tiers are:

  • ⭐ Foundational — one idea, short code, predict-then-run.
  • ⭐⭐ Applied — combine loop transformations, blocking, or vectorization into working code and measure.
  • ⭐⭐⭐ Challenge — a roofline estimate, a subtle dependency, or a design decision; expect to think.

Problems marked have full worked solutions in appendices/answers-to-selected.md (and, for code, in this chapter's code/exercise-solutions.f90). Odd-numbered problems are also solved there. Everything compiles with gfortran -std=f2018 -Wall. Two standing rules apply with special force in a performance chapter: you may not trust a speedup you have not measured (Chapter 28's methodology — warm up, repeat, measure at the real size), and you must verify correctness before you trust timing — a fast wrong answer is worthless.


Part A — Type, Compile, and Run (predict first)

A1 ⭐† Type in example-01-loop-order.f90. Before compiling, predict the interior Laplacian and the value of max |fast - slow|. Then compile and run. In one sentence, say why the two loop orders print the identical field, and in a second sentence say what would differ if the grid were $4000\times4000$ instead of $5\times5$.

A2 ⭐ Predict the output of example-02-fusion-doconcurrent.f90 — the field after one step and both max differences — before running it. Which of the three methods (unfused, fused, do concurrent) computes a different answer, and why is the correct answer "none of them"?

A3 ⭐⭐† Run example-03-blocked-matmul.f90. Predict the $4\times4$ result and max |blocked - matmul| first. Then change the block size nb from 2 to 3, and to 4 (one whole tile). Does the printed matrix change? Explain what nb does and does not affect.

A4 ⭐⭐ Take the Project Checkpoint's optimized stencil. Without running it, hand-compute the field after three steps (continue the $28,32,28$ / $4,4,4$ pattern one more step), then confirm by adding a third call stencil + swap to project-checkpoint.f90. Which interior cells are non-zero after step 3?


Part B — Loop Transformations

B5 ⭐† The fragment below is a two-pass update: it builds a whole grad2(:) array, then applies it. Fuse it into a single loop with no temporary array, hoisting the loop-invariant c = k/dx**2 out of the loop. Confirm your fused version prints the identical y.

do i = 2, n-1
  grad2(i) = ( x(i-1) - 2.0_dp*x(i) + x(i+1) ) / dx**2
end do
do i = 2, n-1
  y(i) = x(i) + k*grad2(i)
end do

B6 ⭐⭐ Count the divisions. In the two-pass fragment of B5, how many floating-point divisions execute per call for n = 1000? In your fused, hoisted version, how many? Explain why a divide is worth hoisting when a multiply usually is not.

B7 ⭐⭐† Loop fission. The loop below mixes a clean, vectorizable computation with a rare, branch-and-call side effect that blocks vectorization of the whole loop:

do i = 1, n
  s(i) = sqrt(x(i)*x(i) + y(i)*y(i))
  if (s(i) > threshold) call record_outlier(i, s(i))
end do

Split it into two loops so the sqrt computation can vectorize, and explain what fission bought and what it cost (there is a cost — name it).

B8 ⭐⭐⭐ Fusion is not always legal. Give a concrete pair of adjacent loops over the same range that you cannot fuse without changing the result, and state the dependency that forbids it. (Hint: consider a second loop that reads an element the first loop has not written yet at that iteration.)


Part C — Cache Blocking and the Roofline

C9 ⭐† Compute the arithmetic intensity (flops per real(dp) byte loaded, order of magnitude) of (a) the five-point stencil update and (b) an $n\times n$ matrix multiply. Which is memory-bound, which is compute-bound, and which one is worth cache blocking? One or two sentences each.

C10 ⭐⭐† Using example-03-blocked-matmul.f90 as a starting point, write a blocked(a, b, c, n, nb) subroutine and verify it against matmul for nb = 2, 3, 4 on a $6\times6$ matrix of your choice. Report max |blocked - matmul| for each nb. Then state: on a large matrix, why does the right nb matter for speed even though it never matters for the answer?

C11 ⭐⭐ Back-of-envelope block size. Your L1 data cache is $32\ \mathrm{KiB}$. For a blocked matrix multiply that must keep three tiles resident (one each of $A$, $B$, $C$), roughly how large can a square real(dp) tile be before it overflows L1? Show the arithmetic. Why is the real best block size usually found by measurement, not this estimate?

C12 ⭐⭐⭐† Roofline crossover. A CPU core sustains $50\ \mathrm{Gflop/s}$ of real(dp) arithmetic and $20\ \mathrm{GB/s}$ of memory bandwidth. Compute the machine balance (flops per byte at which it switches from memory-bound to compute-bound). A kernel does $0.25$ flops per byte loaded — memory- or compute-bound? What does that tell you about whether to optimize its arithmetic or its memory traffic?


Part D — SIMD and do concurrent

D13 ⭐† For each loop body, say whether it can auto-vectorize as written, and if not, name the obstacle: (a) y(i) = 2.0_dp*x(i) + z(i); (b) y(i) = y(i-1) + x(i); (c) y(i) = x(i); if (mask(i)) call f(y(i)); (d) total = total + x(i).

D14 ⭐⭐ The running sum s(i) = s(i-1) + x(i) has a loop-carried dependency. It nonetheless has a fast parallel form (a prefix sum / scan). Without writing it, explain in two sentences why the naive loop cannot vectorize but the total sum(x) (a plain reduction) can be computed with SIMD by partial sums.

D15 ⭐⭐† Rewrite the stencil sweep as a do concurrent loop. Then state precisely the promise you are making to the compiler, and exhibit a one-line change to the body that would break that promise (make the iterations no longer independent) and thereby make the do concurrent produce undefined results.

D16 ⭐⭐ You compile with gfortran -O3 -fopt-info-vec-missed and see: "loop not vectorized: possible aliasing." The loop is subroutine axpy(y, x, a, n) with y and x both intent(inout) pointer arrays. Explain the message and give a source change (an attribute or a redesign) that lets it vectorize.


Part E — Design It (extend the solver)

E17 ⭐⭐† This is the Project Checkpoint as an exercise. Starting from Chapter 24's step, produce an optimized version that (1) uses the correct loop order, (2) fuses the Laplacian and update, (3) hoists the divisions, and (4) is expressed as do concurrent. Then write a verification harness: run the Chapter 24 step and your optimized version on the same random field and error stop unless maxval(abs(u_opt - u_ref)) == 0.0_dp. Why is exactly zero (not a tolerance) the right assertion here?

E18 ⭐⭐ Give the solver's stencil kernel contiguous dummy arguments and explain, in terms of the optimization report, what that attribute lets the compiler stop worrying about. On what kind of actual argument would passing a non-contiguous array to a contiguous dummy be a bug — and how would -fcheck=all help?

E19 ⭐⭐⭐ Two verifications. The stencil update is dependency-free, so the optimized version is bit-identical to the naive one. A reduction — say, computing the total heat sum(u) — is not: SIMD partial sums reassociate the additions and can change the last bit. Write both assertions: a bit-exact == 0 check for the stencil, and a tolerance check <= tol for the reduction, and justify the tolerance you chose in terms of machine epsilon (Chapter 20).


Part F — Port It / Modernize It

F20 ⭐⭐† Port it. Here is a NumPy kernel a colleague wrote (row-major, C order):

for j in range(1, m-1):          # NumPy: last axis is contiguous
    row_new[j] = row[j] + c*(row[j-1] - 2*row[j] + row[j+1])

Port the whole 2D version to Fortran on a field%u(:,:), and — this is the point — say which Fortran loop index must be innermost for cache efficiency, and how that differs from the innermost axis you would choose in NumPy. One kernel, mirrored memory order.

F21 ⭐⭐ Modernize it. The FORTRAN 77 matrix multiply below has the wrong loop order for Fortran's column-major storage (the innermost loop strides across rows). Modernize it to the book's style and reorder the loops for unit-stride access without changing the result. Which loop becomes innermost, and why?

      DO 30 I = 1, N
      DO 20 J = 1, N
      DO 10 K = 1, N
      C(I,J) = C(I,J) + A(I,K)*B(K,J)
   10 CONTINUE
   20 CONTINUE
   30 CONTINUE

Part G — Back of the Envelope

G22 ⭐† Memory traffic. For an $n\times n$ real(dp) field, the Chapter 24 two-pass step reads and writes the field and a full lap temporary; the fused step does neither for lap. Estimate the bytes of memory traffic per step for each, for $n = 1000$, and the fractional saving. Why does that fraction directly predict the speedup for a memory-bound kernel?

G23 ⭐⭐ SIMD ceiling. An AVX register is $256$ bits; AVX-512 is $512$. How many real(dp) values fit in each? What is the ideal vectorization speedup for a perfectly SIMD-friendly loop on each, and name two concrete reasons you will not actually reach it.

G24 ⭐⭐† Divisions saved. Your solver runs $n = 500$, so the interior is $498\times498$ points, for $10{,}000$ time steps. How many floating-point divisions does hoisting the two stencil divisions (1/dx**2, 1/dy**2) out of the inner loop save over the whole run? Express it as a count and comment on whether it is worth the one-line change.


Part H — Interleaved (earlier chapters)

H25 ⭐† (Ch. 5) A colleague's kernel is do i = 1, n; do j = 1, n; a(i,j) = b(i,j) + c(i,j); end do; end do and it is several times slower than it should be on a large array. Name the problem in one phrase, give the corrected loop nesting, and justify it from Fortran's column-major storage.

H26 ⭐⭐ (Ch. 27) The no-aliasing advantage is why the stencil vectorizes. Explain, in three or four sentences, how passing two distinct intent array arguments lets the compiler vectorize, and how switching those arguments to pointer (that might alias) would force it to serialize — the exact hazard §29.4 guards against with contiguous.

H27 ⭐⭐† (Ch. 28) You profile the solver and gprof reports 96% of time in step, 3% in I/O, 1% elsewhere. Cachegrind shows step has a high last-level-cache miss rate. Which optimization from this chapter do you apply first, and why does the profile tell you not to bother optimizing the I/O?

H28 ⭐⭐⭐ (Ch. 21) You have hand-written and cache-blocked a matrix multiply, and it reaches perhaps 8% of your CPU's peak real(dp) rate. A tuned dgemm from OpenBLAS reaches ~80%. Estimate the speedup of switching to dgemm, list three things it does that your blocked loop does not, and state the one-line professional conclusion of §29.5.


Solutions to the starred, odd-numbered, and †-marked problems are in appendices/answers-to-selected.md; the compilable ones are in code/exercise-solutions.f90. If your output disagrees with a hand-computed "Expected output," verify correctness before you touch timing — a fast wrong answer has taught you nothing.