The roofline post ended on a mismatch. Multiplying two clang-1700.0.13.3), once at -O3 -mcpu=native and once with -fno-vectorize -fno-slp-vectorize added, and run on one core of the same Apple-silicon laptop (Apple M1, 8 cores).
All matrices are row-major double. Every variant produced a result bit-identical to the i-j-k version, which is less surprising than it sounds: each one accumulates the
Working sets and the choice of tile
sysctl -n hw.l1dcachesize hw.l2cachesize prints 65536 and 4194304 on this machine. Those are the caches of the efficiency cores; hw.perflevel0.l1dcachesize and hw.perflevel0.l2cachesize report 131072 and 12582912 for the performance cores, where these runs execute, and the 12 MiB L2 is shared by the four of them. The working sets:
| N | one matrix | A + B + C | where it fits |
|---|---|---|---|
| 256 | 512 KiB | 1.5 MiB | either L2 |
| 384 | 1.1 MiB | 3.4 MiB | either L2 |
| 512 | 2 MiB | 6 MiB | the performance cores' L2 only |
| 768 | 4.5 MiB | 13.5 MiB | B fits that L2, all three do not |
| 1024 | 8 MiB | 24 MiB | B fits that L2, all three do not |
In a tiled i-k-j loop the tile that is reused most is the tile of hw.l1dcachesize reports, which leaves room for the rows of
Four loop nests
-
__attribute__((noinline)) void mm_ijk(int N, const double *restrict A, const double *restrict B, double *restrict C) { for (int i = 0; i < N; i++) for (int j = 0; j < N; j++) { double s = 0.0; for (int k = 0; k < N; k++) s += A[i * N + k] * B[k * N + j]; C[i * N + j] = s; } }Each element of
is one chain of dependent multiply-adds, andB[k * N + j]steps a full row ( bytes) at a time. -
__attribute__((noinline)) void mm_ikj(int N, const double *restrict A, const double *restrict B, double *restrict C) { memset(C, 0, sizeof(double) * N * N); for (int i = 0; i < N; i++) for (int k = 0; k < N; k++) { const double a = A[i * N + k]; for (int j = 0; j < N; j++) C[i * N + j] += a * B[k * N + j]; } }The inner loop is now an axpy over a row of
and a row of , both with stride 1. -
__attribute__((noinline)) void mm_blocked(int N, const double *restrict A, const double *restrict B, double *restrict C) { const int T = TILE; /* 64, a compile-time constant */ memset(C, 0, sizeof(double) * N * N); for (int ii = 0; ii < N; ii += T) for (int kk = 0; kk < N; kk += T) for (int jj = 0; jj < N; jj += T) for (int i = ii; i < ii + T; i++) for (int k = kk; k < kk + T; k++) { const double a = A[i * N + k]; for (int j = jj; j < jj + T; j++) C[i * N + j] += a * B[k * N + j]; } }The same i-k-j statement, restricted to one
tile of each matrix at a time. must be a multiple of . -
__attribute__((noinline)) void mm_blocked_reg(int N, const double *restrict A, const double *restrict B, double *restrict C) { const int T = TILE; memset(C, 0, sizeof(double) * N * N); for (int ii = 0; ii < N; ii += T) for (int kk = 0; kk < N; kk += T) for (int jj = 0; jj < N; jj += T) for (int i = ii; i < ii + T; i += 4) for (int j = jj; j < jj + T; j += 8) { double c[4][8]; /* 4x8 tile of C in registers */ for (int r = 0; r < 4; r++) for (int q = 0; q < 8; q++) c[r][q] = C[(i + r) * N + j + q]; for (int k = kk; k < kk + T; k++) for (int r = 0; r < 4; r++) { const double a = A[(i + r) * N + k]; for (int q = 0; q < 8; q++) c[r][q] += a * B[k * N + j + q]; } for (int r = 0; r < 4; r++) for (int q = 0; q < 8; q++) C[(i + r) * N + j + q] = c[r][q]; } }Inside each cache tile, a
block of stays in local variables for the whole range of the tile and touches memory only at the start and the end.
Compiling the four kernels with -Rpass='loop-vectorize|slp-vectorizer' -Rpass-missed=loop-vectorize gives the compiler's account (lines 11, 21, 34 and 49 are the innermost loops in the order of the tabs; line 55 is the final store of the register tile):
matmul.c:11:13: remark: the cost-model indicates that vectorization is not beneficial [-Rpass-missed=loop-vectorize]
matmul.c:21:13: remark: vectorized loop (vectorization width: 2, interleaved count: 4) [-Rpass=loop-vectorize]
matmul.c:34:25: remark: vectorized loop (vectorization width: 2, interleaved count: 4) [-Rpass=loop-vectorize]
matmul.c:49:25: remark: the cost-model indicates that vectorization is not beneficial [-Rpass-missed=loop-vectorize]
matmul.c:55:80: remark: Stores SLP vectorized with cost -1 and with tree size 7 [-Rpass=slp-vectorizer]
matmul.c:55:80: remark: Stores SLP vectorized with cost -2 and with tree size 7 [-Rpass=slp-vectorizer]
matmul.c:55:80: remark: Stores SLP vectorized with cost -4 and with tree size 7 [-Rpass=slp-vectorizer]
The i-j-k loop stays scalar: it is a floating-point reduction over a strided column. The register tile is not vectorised by the loop vectoriser at all; the SLP (superword) vectoriser starts from the eight stores of each row of the tile and packs the accumulators in pairs. The resulting
ldr d24, [x30, x9] ; A[i+0][k]
ldp q25, q26, [x26, #-32] ; B[k][j..j+3]
fmla.2d v0, v25, v24[0]
fmla.2d v1, v26, v24[0]
ldp q27, q28, [x26] ; B[k][j+4..j+7]
fmla.2d v2, v27, v24[0]
fmla.2d v3, v28, v24[0]
ldr d24, [x22, x9] ; A[i+1][k]
... ; 16 fmla.2d in total, one per pair of C values
cmp x9, #512
b.ne LBB3_7
Sixteen independent accumulators, the number the FMA-chain experiment in the roofline post needed to fill the pipes, fed by four scalar loads of
Results
GFLOP/s (
| kernel | build | 256 | 384 | 512 | 768 | 1024 |
|---|---|---|---|---|---|---|
| i-j-k | not vectorised | 2.07 | 1.86 | 1.69 | 1.59 | 1.34 |
| i-k-j | scalar | 7.06 | 7.18 | 7.17 | 7.17 | 6.65 |
| i-k-j | vectorised | 12.82 | 12.48 | 12.44 | 11.98 | 10.24 |
| cache-tiled, T = 64 | scalar | 4.69 | 4.58 | 4.51 | 4.45 | 4.39 |
| cache-tiled, T = 64 | vectorised | 17.81 | 20.93 | 14.52 | 19.08 | 11.32 |
| cache + 4×8 register tile | scalar | 13.12 | 12.82 | 12.54 | 11.36 | 12.14 |
| cache + 4×8 register tile | vectorised | 39.28 | 46.06 | 36.20 | 37.24 | 32.52 |
i-j-k runs at the speed of one dependency chain. 1.3 to 2.1 GFLOP/s, against 1.52 GFLOP/s for one dependent scalar FMA chain in the roofline post. Being a little above that at
i-k-j is a triad in disguise. Swapping two loops gives 7 GFLOP/s scalar and about 12.5 vectorised, and the number barely moves between
Cache tiling helps when the sizes cooperate. The vectorised tiled kernel is 1.4 to 1.7 times faster than i-k-j at
The register tile is the step that matters. 32.5 to 46.1 GFLOP/s, 65 to 93 % of the 49.8 GFLOP/s NEON peak and roughly three times the vectorised i-k-j. Each
Power-of-two sizes
The dips at 512 and 1024 have a classic cause. With
| N | row length (leading dimension) | i-k-j | cache-tiled | register-tiled |
|---|---|---|---|---|
| 512 | 512 | 12.52 | 14.25 | 32.75 |
| 512 | 520 | 12.42 | 21.40 | 39.67 |
| 1024 | 1024 | 9.98 | 11.39 | 31.45 |
| 1024 | 1032 | 10.12 | 18.14 | 37.22 |
Padding by 64 bytes makes the tiled kernel 1.5 to 1.6 times faster and the register-tiled one about 1.2 times, while i-k-j, which streams whole rows and reuses nothing across them, does not notice. That pattern is what conflict misses predict; I did not read the cache's miss counters to confirm it.
TIP
If a blocked kernel is mysteriously slow at exactly the sizes people like to benchmark (512, 1024, 2048), pad the leading dimension by a cache line before touching anything else. BLAS interfaces take a separate leading-dimension argument for this reason.
Choosing the tile, measured
The tile sweep below is at
| T | one tile | cache-tiled, vectorised | cache-tiled, scalar | register-tiled, vectorised | register-tiled, scalar |
|---|---|---|---|---|---|
| 16 | 2 KiB | 25.01 | 18.59 | 40.96 | 13.31 |
| 32 | 8 KiB | 29.74 | 10.92 | 42.58 | 12.80 |
| 64 | 32 KiB | 19.91 | 4.67 | 37.94 | 11.59 |
| 128 | 128 KiB | 10.87 | 4.83 | 35.35 | 11.14 |
| 256 | 512 KiB | 9.67 | 4.94 | 36.23 | 11.58 |
The tile derived from the cache sizes is not the fastest. For the register-tiled kernel it is 11 % behind
WARNING
Make the tile a compile-time constant. An earlier version of this harness read
from a global variable; the cache-tiled kernel then ran at 7.0 to 14.9 GFLOP/s instead of 11.3 to 20.9, because clang could neither unroll nor specialise the short inner loop. The register-tiled kernel, whose inner loop length does not depend on , was within 13 % either way.
On the roofline
On this plot every variant sits at the same horizontal position, the ideal intensity
How the measurements were taken
One thread, QOS_CLASS_USER_INTERACTIVE so that macOS prefers a performance core, timing with clock_gettime_nsec_np(CLOCK_UPTIME_RAW). For each posix_memalign.
Further reading
- Kazushige Goto and Robert van de Geijn, Anatomy of High-Performance Matrix Multiplication, ACM TOMS 2008: the layered tiling and packing behind production GEMM.
- Monica Lam, Edward Rothberg and Michael Wolf, The Cache Performance and Optimizations of Blocked Algorithms, ASPLOS 1991, including self-interference in tiles with unlucky strides.
- BLIS, a framework that exposes the micro-kernel and tiling structure explicitly.
- Ulrich Drepper, What Every Programmer Should Know About Memory, section 6.2, which works through a blocked matrix multiplication.
- Auto-Vectorization in LLVM, on the loop and SLP vectorisers whose remarks appear above.
- Sergey Slotin, Matrix Multiplication in Algorithms for Modern Hardware, the same progression on x86.