TRACK: optimization | DIFFICULTY: 3 | NEEDS: mlir-opt, mlir-translate, clang, taskset
FILES: matmul.mlir (payload), bench_matmul.c (harness), sched/gen.sh (schedule generator), run_matmul.sh (driver)
The usual shape of a perf task on a production MLIR backend: "kernel K is fast with X, slow without X — find X, explain it, make the compiler always do X." This lab runs that loop end to end on the most important kernel there is:
The stages (all driven by a transform script, lab 03_linalg/03):
| letter | stage | transform op |
|---|---|---|
| T | register tiling [TM, TN, 1] | transform.structured.tile_using_for |
| V | vectorize the tile, lower vector.contract → FMA | transform.structured.vectorize + lower_contraction outerproduct |
| H | hoist C's load/store out of the k loop | transform.structured.hoist_redundant_vector_transfers |
$ sched/gen.sh T V H TM=4 TN=16 | grep -E 'tile_using_for|vectorize|hoist|lower_contraction' %t, %loops:3 = transform.structured.tile_using_for %mm tile_sizes [4, 16, 1] transform.structured.vectorize %t : !transform.any_op %f2 = transform.structured.hoist_redundant_vector_transfers %func : (!transform.any_op) -> !transform.any_op transform.apply_patterns.vector.lower_contraction lowering_strategy = "outerproduct"
sched/gen.sh prints the transform script for any subset of stages. Read the
full output once: it's 40 lines and it is the whole optimiser.
./run_matmul.sh
Real run on this machine (i7-1260P, one P-core pinned, -march=native = AVX2+FMA):
A_naive min 230.79 ms p50 235.93 ms 1.16 GFLOP/s maxerr 0.0e+00 OK B_tile_only min 84.43 ms p50 86.88 ms 3.18 GFLOP/s maxerr 0.0e+00 OK C_tile_vec min 17.97 ms p50 18.42 ms 14.94 GFLOP/s maxerr 0.0e+00 OK D_tile_vec_hoist min 8.98 ms p50 10.21 ms 29.89 GFLOP/s maxerr 0.0e+00 OK E_full_noFMA min 16.07 ms p50 16.48 ms 16.70 GFLOP/s maxerr 0.0e+00 OK
(Representative: your times will differ; the ratios between rows are what matters.)
Every row prints maxerr ... OK. A speedup without a correctness check is not a result.
A → B (3×): tiling alone, and NO SIMD. Count packed (...ps) vs scalar (...ss) float instructions:
$ for v in A_naive B_tile_only D_tile_vec_hoist; do printf "%-18s packed=%s scalar=%s\n" $v $(clang -O2 -march=native -S out/$v.ll -o - -Wno-override-module | grep -cE '^\s+v\w+ps\s') $(clang -O2 -march=native -S out/$v.ll -o - -Wno-override-module | grep -cE '^\s+v\w+ss\s'); done A_naive packed=0 scalar=66 B_tile_only packed=0 scalar=64 D_tile_vec_hoist packed=180 scalar=16
A and B are both 100 % scalar. So B's 3× comes from memory order, not from vectors. Look at B's loops:
$ mlir-opt out/B_tile_only.t.mlir --convert-linalg-to-loops | grep -E 'scf.for|memref.load' | sed 's/ :.*//' | awk '{$1=$1};1' | tail -9 scf.for %arg3 = %c0 to %c512 step %c8 { scf.for %arg4 = %c0 to %c512 step %c16 { scf.for %arg5 = %c0 to %c512 step %c1 { scf.for %arg6 = %c0 to %c8 step %c1 { scf.for %arg7 = %c0 to %c16 step %c1 { scf.for %arg8 = %c0 to %c1 step %c1 { %0 = memref.load %subview[%arg6, %arg8] %1 = memref.load %subview_0[%arg8, %arg7] %2 = memref.load %subview_1[%arg6, %arg7]
In A (naive: m, n, k innermost) every k step walks down a column of B: a new cache line per multiply, 512 lines per dot product. In B, k sits outside an 8×16 tile, so each k step reads 16 contiguous floats of one B row, and the 8×16 C tile stays in L1. Same FLOPs, far fewer cache misses.
B → C (4.7×): vectorization in MLIR. 180 packed instructions appear. The
tile body became vector.fma on vector<16xf32> (2 × ymm). LLVM didn't have to
guess anything: the vector shapes came from the tile sizes.
C → D (2×): hoisting. Without H, every k iteration loads the C tile from memory, does one FMA per row, and stores it back: a load+store per FMA. With H the k loop carries C in registers:
$ grep -m1 -E 'scf.for .*iter_args' out/D_tile_vec_hoist.t.mlir | sed 's/^ *//' %2 = scf.for %arg5 = %c0 to %c512 step %c1 iter_args(%arg6 = %1) -> (vector<8x16xf32>) { $ grep -c 'iter_args' out/C_tile_vec.t.mlir 0
iter_args(... -> (vector<8x16xf32>): the accumulator IS the loop-carried
value. That's the "microkernel" shape every BLAS uses.
D → E (1.8× slower): same IR, weaker target. -march=x86-64 = SSE2: 4-wide
vectors and no FMA instruction (fma is emulated as mul + add). Identical MLIR,
different machine flags. On AArch64 the same trap is forgetting +sve,
+i8mm, +bf16, or -mcpu (lab 05_aarch64).
./run_matmul.sh sweep
Real run (GFLOP/s, min of 5):
TM\TN 8 16 32 64 1 8.95 16.22 25.66 26.00 2 16.75 26.62 34.15 23.51 4 24.37 33.09 22.77 33.66 8 35.98 30.45 33.94 17.45 12 (vectorize failed — see STEP 5) 16 26.26 41.42 32.85 27.18
Now the honest part: this table is mostly noise. Re-running four entries three times each:
tile_16x16 26.92 28.43 41.19 <- 1.5x spread on the SAME binary tile_4x16 42.22 43.87 46.39 tile_4x32 25.97 26.25 29.46 tile_8x8 40.31 40.41 40.82
A laptop with turbo and a powersave governor moves 30–50 % between runs.
Rules (same as ~/debug_lab/perf):
$ for v in tile_4x16 tile_8x16 tile_16x16 tile_8x64 tile_16x64; do printf "%-12s spills=%s\n" $v $(clang -O2 -march=native -S out/$v.ll -o - -Wno-override-module | grep -c 'Spill$'); done tile_4x16 spills=0 tile_8x16 spills=18 tile_16x16 spills=84 tile_8x64 spills=232 tile_16x64 spills=486
AVX2 has 16 ymm registers. A TM×TN f32 tile needs TM×TN/8 accumulators, plus a register for the B row and one for the broadcast A element:
On AArch64 NEON there are 32 × 128-bit registers (4 f32 each), so the
sweet spot moves: 8×8 or 8×12 tiles are common. With SVE the tile is
[8, [4]], "4 × vscale" wide (lab 05_aarch64). This is why IREE's
KernelDispatch.cpp picks tile sizes per target, and why a production MLIR backend will
need the same logic.
$ ./run_matmul.sh one tile_12x16 "T V H TM=12 TN=16" 2>&1 | head -2 matmul.mlir:7:3: error: Attempted to vectorize, but failed linalg.matmul ins(%A, %B : memref<512x512xf32>, memref<512x512xf32>)
512/12 isn't whole, so the last tile is 8 rows: a dynamic shape. Plain
vectorize needs static shapes. Three standard fixes, all used in real compilers:
transform.structured.vectorize %t vector_sizes [12, 16, 1]
(vector ops with a mask for the ragged edge; SVE does this in hardware).transform.loop.peel, IREE's LLVMCPUPeel.cpp).transform.structured.pad).Triton kernels hit this constantly: BLOCK_SIZE rarely divides the runtime n.
bench_*.c).perf stat counters), never from timing alone../run_matmul.sh one kc128 "T V H TM=4 TN=16 KC=128".
Does it help at 512? Change N in both files to 2048 and try again.perf stat -e cycles,instructions,L1-dcache-load-misses on A_naive
vs B_tile_only. Does the miss count explain the 3×?fmla
in the inner loop. Then change TN to 8 and TM to 8 and count again.A lab is done when you can say what it showed on your machine — not when you have read it.
It decides which problems come first, which drills open, which labs you see and how the Learn surface is organised. Change it any time from the switch in the top bar, or press Escape to look around first.