Case Study 2: Designing a Kernel the Compiler Will Vectorize
"The fastest code is the code you never had to hand-tune, because you handed the compiler a problem it could solve."
Executive Summary
Where the first case study diagnosed someone else's slow loop, this one asks you to build a fast one
from the start — and, just as importantly, to build the small apparatus that proves it is fast. You will
design a numerical kernel and a minimal "does-it-vectorize?" harness so that every principle of this chapter
is working together on purpose: distinct arrays so the no-aliasing advantage applies, pure/elemental
helpers so the optimizer is free, the first-index-inner loop order so memory streams with the grain, and the
optimization report as the acceptance test. Then you will reason about the ceiling — the arithmetic
intensity that decides whether vectorization can help at all — so you know when this effort pays and when
the loop is memory-bound no matter what. This is the design mindset Chapter 29
builds into a full discipline.
Skills applied: structuring code for auto-vectorization (§27.1, §27.4); guaranteeing non-aliasing by
construction (§27.3); cache-friendly loop order (§27.2); reading -fopt-info as an acceptance test (§27.5);
arithmetic-intensity / roofline reasoning to predict the ceiling; honest timing (measured, not asserted).
Background
Your task is a kernel used in every simulation and benchmark suite: the vector triad, $c_i = a_i + s\, b_i$, the STREAM benchmark's namesake and the simplest kernel that stresses both arithmetic and memory. It is deliberately humble, because the goal is not the algorithm — it is the design method. If you can make the compiler vectorize a triad and can prove that it did, you can do it for the stencils and reductions that matter, because the discipline is identical.
We set four design goals up front, each traceable to a section of this chapter:
| Goal | Why | Section |
|---|---|---|
| Distinct output and input arrays | Lets the no-aliasing guarantee apply — no runtime overlap check | §27.3 |
pure/elemental helpers only |
No side effect in the loop body to block reordering/vectorization | §27.4 |
| Inner loop over the first index | Streams memory with the column-major grain | §27.2 |
| Whole-array or simple counted loop | Hands the compiler structure, not a puzzle | §27.1 |
Phase 1 — Write the Kernel to Vectorize
The kernel, written to hit all four goals at once. Note what is absent: no pointers, no aliasing, no side effects, no exotic control flow — nothing for the compiler to be afraid of.
module triad_mod
use, intrinsic :: iso_fortran_env, only: dp => real64
implicit none
contains
! c = a + s*b. Distinct arrays (no aliasing), pure (no side effects),
! whole-array (structure handed to the compiler). Practically begs to vectorize.
pure subroutine triad(a, b, s, c)
real(dp), intent(in) :: a(:), b(:), s
real(dp), intent(out) :: c(:)
c = a + s * b
end subroutine triad
end module triad_mod
Verify correctness on values you can check in your head before you care about speed:
program check_triad
use, intrinsic :: iso_fortran_env, only: dp => real64
use triad_mod, only: triad
implicit none
real(dp) :: a(4) = [1.0_dp, 2.0_dp, 3.0_dp, 4.0_dp]
real(dp) :: b(4) = [1.0_dp, 1.0_dp, 1.0_dp, 1.0_dp]
real(dp) :: c(4)
call triad(a, b, 3.0_dp, c) ! c = a + 3*b = [4, 5, 6, 7]
print '(a, 4f6.1)', 'triad c = a + 3*b : ', c
end program check_triad
$ gfortran -std=f2018 -Wall -O3 -march=native check_triad.f90 -o check && ./check
triad c = a + 3*b : 4.0 5.0 6.0 7.0
The hand check: c = [1+3·1, 2+3·1, 3+3·1, 4+3·1] = [4, 5, 6, 7]. Correctness first; now we make the same
kernel prove its speed.
Phase 2 — Build the "Does It Vectorize?" Harness
A benchmark you cannot trust is worse than none. The minimal honest harness times the kernel over a large array, runs it several times, and — crucially — consumes the result so the compiler cannot delete the work as dead code. (The rigorous version — warm-up passes, discarding the first run, reporting variance — is Chapter 28; here we build the honest skeleton.)
program bench_triad
use, intrinsic :: iso_fortran_env, only: dp => real64, int64
use triad_mod, only: triad
implicit none
integer, parameter :: n = 2**20 ! 1048576 (~1e6), fits a default integer
integer, parameter :: reps = 50
real(dp), allocatable :: a(:), b(:), c(:)
integer(int64) :: t0, t1, rate
real(dp) :: checksum
integer :: k
allocate(a(n), b(n), c(n))
a = 1.0_dp; b = 2.0_dp ! c should be 1 + 3*2 = 7 everywhere
call system_clock(count_rate=rate)
call system_clock(t0)
do k = 1, reps
call triad(a, b, 3.0_dp, c)
end do
call system_clock(t1)
checksum = c(1) + c(n) ! consume the result -> no dead-code elimination
print '(a, f0.1)', 'checksum (should be 14.0) = ', checksum
print '(a, f10.4, a)', 'elapsed (illustrative) = ', real(t1 - t0, dp)/real(rate, dp), ' s'
end program bench_triad
The checksum is exact and hand-computable — every element of c is 1 + 3·2 = 7, so c(1) + c(n) = 14.0
— which both guards against dead-code elimination and gives us a correctness anchor. The elapsed time is
illustrative and is not shown as a number: this book runs nothing.
$ gfortran -std=f2018 -Wall -O3 -march=native bench_triad.f90 -o bench && ./bench
checksum (should be 14.0) = 14.0
elapsed (illustrative) = <hardware-dependent> s
Phase 3 — Make the Compiler Report Its Verdict
Design without verification is hope. Turn the optimization report into an acceptance test: the kernel is "done" only when the compiler confirms the loop vectorized.
$ gfortran -std=f2018 -O3 -march=native -fopt-info-vec triad.f90 -o bench
triad.f90:NN:NN: optimized: loop vectorized using 32 byte vectors
"32 byte vectors" means four real(dp) per instruction — an AVX register's worth. If instead you see
nothing (and -fopt-info-vec-missed names a reason), the kernel failed the acceptance test and you go back
to the four goals to find which one you broke. Cross-check on Compiler Explorer (godbolt.org): the inner
loop should be built from packed instructions such as vaddpd/vfmadd…pd, not the scalar
addsd/mulsd. Seeing the packed instructions is the design succeeding, made visible.
The reasoning that matters: the four design goals are not superstition — each removes a specific reason the compiler would otherwise refuse to vectorize. Distinct arrays remove the aliasing doubt; purity removes the side-effect doubt; the counted whole-array form removes the structure doubt; and correct loop order makes the vectorized loads worth doing. Break any one and the report tells you exactly which, because the report is downstream of the same four conditions.
Phase 4 — Know the Ceiling: Arithmetic Intensity
Here is the sober part, and the mark of an engineer rather than a cargo-culter: vectorization has a ceiling, and for the triad the ceiling is low. Vectorization speeds up arithmetic; if a loop spends its time waiting on memory, four-wide arithmetic finishes its work only to wait four times as eagerly for the next data. Whether that happens is set by the arithmetic intensity — flops performed per byte moved.
Count it for the triad c = a + s*b, per element: 2 flops (one multiply, one add) and, in real(dp), 3
array touches — read a, read b, write c — at 8 bytes each, so 24 bytes. That is
$2 / 24 \approx 0.083$ flops per byte: a tiny intensity. The triad is emphatically memory-bound. Its
speed is set by memory bandwidth, and vectorizing the arithmetic, while correct and worth doing, cannot lift
it past what the memory system can deliver.
| Kernel | Flops/elem | Bytes/elem (dp) | Intensity (flop/byte) | Bound by |
|---|---|---|---|---|
Triad c = a + s*b |
2 | 24 | ≈ 0.08 | Memory |
| 5-point stencil (one array in, one out) | ≈ 6 | 16 | ≈ 0.4 | Memory |
| Dense matmul (n×n) | ≈ 2n³ | ≈ 24n² | ≈ n/12 | Compute (for large n) |
The lesson is strategic. For low-intensity kernels (triad, stencil), the returns come from moving less
memory — better loop order, cache blocking, fusing passes — the material of
Chapter 29, not from squeezing more flops. For
high-intensity kernels (matmul), vectorization and register tiling pay off enormously, which is why a tuned
BLAS dgemm is so fast and why you should call it rather than reproduce it
(Chapter 21). Knowing which
regime you are in before you optimize is the roofline intuition that
Chapter 38 formalizes.
Phase 5 — Generalize the Method
Lift the triad's lessons into a checklist you can apply to any kernel you write:
- Design for non-aliasing. Separate output arrays from input arrays; never pass one array as both a written and a read argument. The no-aliasing advantage is free only if you honor the contract.
- Keep the hot loop pure. No I/O, no global-state updates, no impure calls in the inner loop. Mark
helpers
pure/elementalso the compiler enforces it and is freed by it. - Loop in the memory grain. Inner loop over the first index, always, for column-major arrays.
- Prefer whole-array or simple counted loops. Hand the compiler structure, not a pointer puzzle.
- Make the compiler confirm it.
-fopt-info-vecis the acceptance test; packed assembly on Godbolt is the receipt. - Then check the ceiling. Estimate arithmetic intensity to know whether you are memory- or compute-bound, so you optimize the thing that actually limits you.
Run that checklist and you will write kernels that are fast for the right reason — because they gave a very good compiler a problem it was designed to solve — and you will be able to prove it, which is the whole difference between engineering and hoping.
Discussion Questions
- The triad has arithmetic intensity ≈ 0.08 flop/byte and vectorizes cleanly. Reconcile these: how can a loop be "fully vectorized" and yet see little speedup from vectorization? What is actually limiting it?
- Goal 1 (distinct arrays) and Goal 2 (purity) both remove a "doubt" the compiler would otherwise have. Describe the specific doubt each removes, and what the compiler is forced to do while the doubt remains.
- Your harness prints a checksum "to prevent dead-code elimination." Explain the failure mode if you omit it: what might the compiler legally do to a benchmark whose result is never used, and how would that corrupt your timing?
- Given the intensity table, which of the three kernels most rewards
-march=native(wider vectors), and which would barely notice it? Defend your ranking.
Your Turn: Extensions
- Option A. Replace the triad with the five-point stencil kernel and repeat the whole method: write it to
the four goals, build the harness with an exact checksum (use the maximum-principle bound from the
checkpoint —
maxvalstays on the hot edge), and confirm vectorization with-fopt-info-vec. - Option B. Deliberately break one design goal at a time (alias the arrays; add a
printto a helper; reverse the loop order) and record, for each, what-fopt-info-vec-missedreports. Build a small table mapping each broken goal to the compiler's complaint — a diagnostic Rosetta Stone. - Option C. Compute the arithmetic intensity of your own solver's hot loop, place it on the intensity table above, and write a one-paragraph prediction of whether loop-order/vectorization or memory-reduction/blocking will help it more — then, after Chapter 29, check your prediction against a measurement.
Key Takeaways
- Fast kernels are designed, not discovered: distinct arrays, pure hot loops, first-index-inner order, and simple structure give the compiler everything it needs to vectorize.
- A benchmark must consume its result (a checksum) or the optimizer may delete the very work you are timing.
- Vectorization has a ceiling set by arithmetic intensity. Low-intensity kernels (triad, stencil) are memory-bound — optimize memory traffic, not flops; high-intensity kernels (matmul) reward vectorization, and the tuned library rewards it most.
- The optimization report is the acceptance test that turns a design intention into a verified fact.