Before optimising a loop it helps to know which wall it is pressed against: the rate at which the core can do arithmetic, or the rate at which memory can feed it. The roofline model of Williams, Waterman and Patterson answers that with two machine numbers and one number per kernel. This post, the first in a short series on single-node performance, measures both machine numbers on one core of an Apple-silicon laptop (sysctl -n machdep.cpu.brand_string prints Apple M1, hw.ncpu prints 8) and then places real kernels under the result. Everything below was compiled with Apple clang 16.0.0 (clang --version: Apple clang version 16.0.0 (clang-1600.0.26.6)) at -O3 -mcpu=native, and every number comes from the programs shown.
Arithmetic intensity
The kernel's number is its arithmetic intensity: the floating-point operations it performs per byte it moves between the core and the memory level under study,
Two kernels in double precision make the range concrete. The STREAM triad a[i] = b[i] + s*c[i] loads two 8-byte values, stores one and performs one fused multiply-add:
The bound
Let
On log-log axes the right-hand side is a line of slope one that meets a horizontal line at the ridge point
Measuring β with a triad
The bandwidth roof comes from a STREAM-style triad on one core:
__attribute__((noinline)) void triad(double *restrict a, const double *restrict b,
const double *restrict c, double s, size_t n) {
for (size_t i = 0; i < n; i++) a[i] = b[i] + s * c[i];
}
clang turns the loop body into one fmla.2d per two elements, so the 2-flops-per-element count holds. The harness touches every page before timing, repeats the kernel until each trial has moved about 1.5 GB (or one pass, for the large arrays), discards three warm-up trials, keeps fifteen, and runs the whole sweep three times: each size gets 45 trials. Array sizes run from 4 KiB to 256 MiB per array in half-octave steps.
Three plateaus are visible. About 196 GB/s holds up to 64 KiB per array, which is consistent with the two arrays that are read (128 KiB together) fitting in the 128 KiB L1 data cache of a performance core while the stores drain further out. About 124 GB/s holds while the data lives in the 12 MiB L2, and the curve settles between 8 and 24 MiB of working set. STREAM's run rules ask for each array to be at least four times the last-level caches used in the run, here the 12 MiB L2, so at least 48 MiB, so the DRAM roof comes from the three sizes that qualify:
| bytes per array | best of 45 (GB/s) | median (GB/s) |
|---|---|---|
| 32 MiB (too small for the rule) | 44.0 | 39.5 |
| 64 MiB | 41.6 | 37.0 |
| 128 MiB | 48.3 | 39.3 |
| 256 MiB | 47.3 | 37.5 |
I take
Measuring π with independent FMA chains
A fused multiply-add depends on its accumulator, so a loop that updates one accumulator runs at the latency of the instruction, not its throughput. The compute roof therefore comes from a kernel that updates
#define VEC_KERNEL(K) \
__attribute__((noinline)) double vec_##K(double seed) { \
float64x2_t acc[K]; \
const float64x2_t x = vdupq_n_f64(1e-5), y = vdupq_n_f64(1e-4); \
_Pragma("clang loop unroll(full)") \
for (int k = 0; k < K; k++) acc[k] = vdupq_n_f64(seed + k); \
for (long it = 0; it < ITERS; it++) { \
_Pragma("clang loop unroll(full)") \
for (int k = 0; k < K; k++) acc[k] = vfmaq_f64(acc[k], x, y); \
} \
float64x2_t s = acc[0]; \
_Pragma("clang loop unroll(full)") \
for (int k = 1; k < K; k++) s = vaddq_f64(s, acc[k]); \
return vaddvq_f64(s); \
}
The inner loop compiles to exactly fmla.2d instructions plus the loop counter. A scalar twin uses __builtin_fma on double accumulators; that file is built with -fno-vectorize -fno-slp-vectorize added, so the scalar version stays scalar. Each cell is the median of three per-pass medians, each over 21 timings:
| independent chains | NEON fmla.2d (GFLOP/s) | scalar fmadd (GFLOP/s) |
|---|---|---|
| 1 | 3.05 | 1.52 |
| 2 | 6.08 | 3.06 |
| 4 | 12.25 | 6.11 |
| 8 | 24.09 | 12.15 |
| 12 | 36.00 | 17.86 |
| 16 | 47.92 | 24.16 |
| 20 | 49.06 | 24.41 |
| 24 | 48.63 | 24.37 |
Each chain adds about 3 GFLOP/s until sixteen, then nothing. The single chain measures latency; the plateau measures throughput; their ratio, sixteen, is how many multiply-adds must be in flight to keep the units busy, which is Little's law applied to a pipeline. If the FMA latency is four cycles, the numbers imply a clock of about 3.05 GHz and four 128-bit FMAs issued per cycle; I did not measure the clock directly. The best single timing gives
TIP
One dependent chain uses a sixteenth of this core's floating-point capacity. Splitting an accumulator into several partial sums is the cheapest optimisation in this series, and the compiler will not do it for floating-point reductions on its own, because reassociating the additions changes the rounding.
With both numbers the ridge sits at
Kernels under the roof
To walk a kernel across the ridge I used the polynomial from the figure, with every coefficient equal to c so that the coefficients live in one register, and the degree D fixed at compile time:
for (size_t i = 0; i < n; i++) { /* n = 16 Mi doubles: 128 MiB per array */
double xi = x[i], acc = c;
for (int k = 0; k < D; k++) acc = acc * xi + c; /* fully unrolled */
y[i] = acc;
}
Each element costs 16 bytes and
| degree d | I (flop/byte) | roof | clang -O3 | 12 chains, intrinsics |
|---|---|---|---|---|
| 1 | 0.125 | 5.7 | 5.27 | 3.19 |
| 2 | 0.25 | 11.4 | 9.17 | 6.04 |
| 4 | 0.5 | 22.9 | 18.87 | 14.48 |
| 8 | 1 | 45.7 | 27.59 | 20.93 |
| 16 | 2 | 49.8 | 26.18 | 30.17 |
| 32 | 4 | 49.8 | 23.21 | 30.95 |
| 64 | 8 | 49.8 | 15.44 | 30.52 |
| 128 | 16 | 49.8 | 9.09 | 29.95 |
GFLOP/s, medians of three passes of eleven timings. The dot product ran at 4.61 GFLOP/s (roof 5.7) and the triad at 3.40 (roof 3.8).
Left of the ridge these kernels reach the slope. Degrees 1 to 4, the dot product and the triad all land at 80 to 92 % of
Right of the ridge the compiled loop gets slower as the work grows. The vectoriser's remarks explain it: for vectorized loop (vectorization width: 2, interleaved count: 4), but for interleaved count: 1. Each loop iteration is then one vector carrying two elements through
Exposing independent chains lifts it to a plateau of about 30 GFLOP/s, 61 % of π. The remaining gap has a visible cause in the assembly: NEON's fmla overwrites its addend, and in Horner's rule the addend is the constant c, so every step needs a register copy before the multiply-add:
mov.16b v31, v1 ; copy c
fmla.2d v31, v17, v8 ; c + x * acc
mov.16b v8, v1
fmla.2d v8, v16, v9
That doubles the instruction count per flop. Without hardware counters I cannot prove that the copies are what caps the plateau, but the plateau sits where a loop with two instructions per FMA would be expected to sit, well above the scalar ceiling and well below π. The same intrinsics kernel is slower than clang's loop at low degree, where it streams memory less efficiently; I tuned it for the compute-bound end only.
WARNING
The roof is a measurement of one access pattern, not a law. Individual best trials of the triad inside this round-robin harness reached 54 GB/s and of the dot product 49 GB/s, above the 46 GB/s roof from the separate sweep, and a one-read-one-write stream like the low-degree polynomials does not have the same bandwidth as the triad's two-reads-one-write. On this machine I would not draw conclusions from differences smaller than about 15 % in the memory-bound region.
How the measurements were taken
All runs use one thread. Timing uses clock_gettime_nsec_np(CLOCK_UPTIME_RAW). macOS has no API to pin a thread to a core, so each benchmark calls pthread_set_qos_class_self_np(QOS_CLASS_USER_INTERACTIVE, 0), which makes the scheduler prefer the performance cores. Kernels are noinline, results feed a checksum that is printed, and arrays are allocated with posix_memalign and touched before timing. Each benchmark was run three times as a whole, at times when the one-minute load average was below 80. The roofs use best-of values (the STREAM convention: a roof is an upper bound) and every kernel point is the median of the three per-pass medians. One pass of the polynomial sweep finished with the load average at 126; I excluded it and ran a replacement.
What the ridge means for matrix multiplication
A ridge near 1 flop/byte is low: one flop per byte of DRAM traffic is enough for the arithmetic roof, not DRAM, to be a kernel's ceiling. Dense matrix multiplication clears it by a wide margin on paper.
which is 64 flop/byte at s += A[i][k] * B[k][j] loads two doubles per multiply-add, an intensity of 1/8, and if those bytes came from DRAM the roof would be 5.7 GFLOP/s. The whole difference between
The next post measures the classic loop orders and tiling against exactly these lines, and the SIMD post looks at the vector instructions that the flat part of the roof assumes.
Further reading
- Samuel Williams, Andrew Waterman and David Patterson, Roofline: An Insightful Visual Performance Model for Multicore Architectures, Communications of the ACM, 2009, and the longer Berkeley technical report.
- John McCalpin, STREAM: Sustainable Memory Bandwidth in High Performance Computers, with the run rules on array sizes.
- Ulrich Drepper, What Every Programmer Should Know About Memory, for the cache and DRAM mechanics behind the bandwidth plateaus.
- The Empirical Roofline Toolkit from Berkeley Lab, which measures roofs on any machine, and NERSC's notes on roofline performance analysis.
- Sergey Slotin, Algorithms for Modern Hardware, especially the chapters on instruction-level parallelism and memory.