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,

I=WQ[flopbyte].
Counting bytes and flops: the same loop shape, two regimes STREAM triad a[i] = b[i] + s * c[i] b[i] load 8 B c[i] load 8 B a[i] store 8 B 1 mul + 1 add 2 flops, one FMA I = 2 flops / 24 bytes = 0.083 flop/byte far below the ridge: memory-bound Degree-64 polynomial (Horner) acc = acc * x[i] + c, 64 times x[i] load 8 B y[i] store 8 B 64 fused multiply-adds 128 flops, all in registers each depends on the previous one I = 128 flops / 16 bytes = 8 flop/byte right of the ridge: compute-bound Ridge point I* = π / β: below it the roof is β · I, above it the roof is flat at π Bytes are counted at the level of the hierarchy that actually feeds the kernel; here the arrays are 128 MiB each, so that level is DRAM.
The same loop shape with two very different intensities. The triad moves 24 bytes for 2 flops; the polynomial moves 16 bytes for 128.

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: I=2/24≈0.083. A degree-64 polynomial evaluated by Horner's rule loads one value, stores one and performs 64 multiply-adds in registers: I=128/16=8. A cache that allocates on write would also read each destination line before overwriting it, so the hardware may move 32 bytes per triad element rather than 24. STREAM counts 24, and so do I, for the roof and for the kernels alike; what matters is that the convention is applied the same way to both.

The bound

Let π be the peak arithmetic rate and β the peak bandwidth of the level that feeds the kernel. A kernel with intensity I cannot run faster than either limit allows:

P(I)≤min(π,βI).

On log-log axes the right-hand side is a line of slope one that meets a horizontal line at the ridge point I∗=π/β. Left of the ridge the bandwidth roof is the lower bound, so the best a kernel there can do is run at memory speed, and only more bandwidth or more reuse raises that ceiling; right of the ridge the arithmetic roof is the lower one. The position says which roof caps a kernel, not what holds a particular implementation below it. A scalar dot product sits left of the ridge, but if every addition waits for the previous one it can run no faster than a single dependent chain, 1.5 GFLOP/s on this core as measured below, far under its bandwidth roof; splitting the sum into independent chains would leave its intensity unchanged and still raise its speed. The paper also draws ceilings under the flat roof, lower limits that apply when a kernel cannot use some feature of the core, such as SIMD lanes or enough independent operations. Two of them are measured below.

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.

Triad bandwidth against total working set from 12 KiB to 768 MiB: about 196 GB/s up to 192 KiB, about 124 GB/s up to 6 MiB, falling between 8 and 24 MiB to roughly 40 to 48 GB/s.
Triad bandwidth on one performance core, best of 45 trials and median, against the size of all three arrays together. Dotted lines: the performance core's 128 KiB L1d and the 12 MiB L2 (sysctl hw.perflevel0.*).

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 β=46 GB/s, the mean of the three best values. The spread between best and median is real: this was a laptop in normal use, and DRAM-bound timings were the noisiest of everything I measured.

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 K independent accumulators held in registers, with no memory traffic at all:

#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 K 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 π=49.8 GFLOP/s in double precision. The scalar plateau is half of that: the same number of FMAs per second, one lane instead of two.

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 I∗=49.8/46≈1.1 flop/byte.

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 2D flops, so I=D/8 and D=1,2,…,128 spans 1/8 to 16 flop/byte. A second version, written with NEON intrinsics, evaluates twelve vectors (24 elements) at a time and interleaves their steps, so that twelve independent chains are always available. The dot product (two loads per multiply-add, I=1/8) and the triad complete the set. All kernels were timed round-robin within each trial, so slow periods on the machine hit them equally.

Log-log roofline. A black roof rises at 46 GB/s and flattens at 49.8 GFLOP/s at about 1.1 flop/byte. Dashed L1 and L2 slopes, dotted ceilings for scalar FMA and one dependent chain. Horner points follow the slope up to degree 8; the clang -O3 points then fall to 9 GFLOP/s at degree 128 while the 12-chain points stay near 30.
The measured roofline (best-of values) with kernel medians on 128 MiB arrays. Left of the ridge the kernels follow the bandwidth slope; right of it the compiled loop is limited by dependency chains, not by memory.
degree d I (flop/byte) roof min(π,βI) 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 βI, as close to the bandwidth roof as the noise on this machine allows, so cheaper arithmetic has little left to gain here.

Right of the ridge the compiled loop gets slower as the work grows. The vectoriser's remarks explain it: for D≤8 clang reports vectorized loop (vectorization width: 2, interleaved count: 4), but for D≥16 it reports interleaved count: 1. Each loop iteration is then one vector carrying two elements through D dependent multiply-adds, and the only other parallelism is whatever the out-of-order core can overlap between consecutive iterations. The longer the chain, the fewer iterations fit in that window: degree 64 runs at about five chains' worth of the single-chain rate (15.4 / 3.05) and degree 128 at about three. The kernel is compute-bound in the roofline sense and latency-bound in practice.

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. C=AB for N×N matrices performs 2N3 flops; if each matrix crossed the memory bus exactly once (read A and B, read and write C), the traffic would be 32N2 bytes and the intensity

Iideal=2N332N2=N16,

which is 64 flop/byte at N=1024, sixty times the ridge. Every extra trip a matrix makes over the bus lowers it, so this is the best case, not a typical value. The textbook loop is nowhere near that. Its inner statement 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 1/8 and N/16 is reuse: whether a value brought into cache is used once or many times before it is evicted. Once the data does live in cache, the slope that matters is the cache's, 124 GB/s for L2 and 196 GB/s for L1, not DRAM's.

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