37. Inside the LLVM Stage: Reading the IR, and Why a Loop Does or Does Not Vectorize¶
What you will understand: what happens to a Mountain Goat program after MLIR hands it to LLVM. Every chapter since Chapter 5 has ended with "lowered to LLVM IR, then clang makes the executable", and none has opened that stage. This chapter opens it. You will read the LLVM IR that mgc writes; watch LLVM's optimizer, opt, change a matrix product pass by pass; ask the loop vectorizer why it did or did not vectorize a loop (Chapter 23 counted vector instructions and Chapter 24 got a 3× to 11× speedup from a loop-order change, and neither said why); read the assembly of the two inner loops; and ask llvm-mca, a model of the CPU's pipeline, what it predicts. The chapter ends with two corrections to its own first experiments and one place where the model predicts a speedup that the measurement refuses to show.
What you need to know first: Chapter 5 (the lowering to the LLVM dialect and IR), Chapter 23 (the benchmark, -O3 -march=native) and Chapter 24 (the ikj loop order). No new compiler operation is needed and the compiler is unchanged: the programs run on Chapter 32's mgc, and the tools are LLVM 18's own: opt-18, clang-18, llvm-mca-18.
Compile and run
cd docs/part32/code && ./build.sh # once: the newest compiler (this chapter adds nothing to it)
cd ../../part37/code
./stages.sh > stages_out.txt # the files mgc writes on the way to machine code, for the smallest program and for the matrix product
./walk.sh > walk_out.txt # opt, pass by pass, on the matrix product's IR
./remarks.sh > remarks_out.txt # the loop vectorizer's decisions and reasons
./ops_table.sh > ops_table_out.txt # which of Mountain Goat's operations vectorize (instruction counts)
./mca.py > mca_out.txt; ./mca.py --loops > loops_out.txt # llvm-mca's prediction, and the assembly of the two inner loops
./cpu_dependence.py > cpu_dependence_out.txt # the same IR compiled for four different CPUs (compile only)
./variants.sh > variants_out.txt; ./summarize_variants.py > variants_summary.txt # the measurement: 3 passes of the Chapter 24 benchmark (about 2 minutes)
./check_llvm.py > check_llvm_out.txt # every claim on this page, tested for the pinned CPU (about 3 seconds)
./claims_mutation.py > claims_mutation_out.txt # six WRONG inputs the checker must catch (about 20 seconds)
cd ../../part15/code && ./run_lit.sh # the whole test suite
Why this page shows FAIL lines and 'not vectorized' remarks, and why that is good
"Loop not vectorized" is not an error: it is LLVM explaining a decision, and the explanation is the subject of the chapter. The claims_mutation.py output is a negative control: deliberately wrong inputs fed to the checker, where "caught" is the wanted result and "NOT CAUGHT" would be a gap.
Where LLVM sits in mgc¶
mgc is a chain of real tools (its header says so). With MGC_KEEP=dir it keeps the file each stage writes. For the smallest program (examples/01_add2x2.mg, a + b on 2 × 2 matrices) and for the 64 × 64 matrix product (examples/02_matmul64.mg), the stages and their sizes are:
== add2: lines per stage
01_add2x2.mlir 4 lines
01_add2x2.affine.mlir 15 lines
01_add2x2.llvm.mlir 77 lines
01_add2x2.ll 73 lines
== mm_ijk: lines per stage
02_matmul64.mlir 4 lines
02_matmul64.affine.mlir 29 lines
02_matmul64.llvm.mlir 113 lines
02_matmul64.ll 118 lines
== mm_ikj: lines per stage
02_matmul64.mlir 4 lines
02_matmul64.affine.mlir 29 lines
02_matmul64.llvm.mlir 113 lines
02_matmul64.ll 118 lines
mgfront.py writes the .mlir (the mg dialect), mg-opt --convert-mg-to-affine writes the .affine.mlir (loops, loads, stores), mg-opt with the lowering passes writes the .llvm.mlir (the LLVM dialect: MLIR's own copy of LLVM's instructions), and mlir-translate writes the .ll, which is LLVM IR: the text format LLVM's tools read. From here on nothing is MLIR: opt optimizes the .ll, and clang turns it into assembly and an executable. (The two loop orders have the same line counts: only the order of the three loops inside differs.)
Reading the IR¶
Here is the whole LLVM IR of the 2 × 2 add. Take it in four parts:
---- add2/01_add2x2.ll (the whole file, the LLVM IR of the small program)
; ModuleID = 'LLVMDialectModule'
source_filename = "LLVMDialectModule"
declare ptr @malloc(i64)
define { ptr, ptr, i64, [2 x i64], [2 x i64] } @add2(ptr %0, ptr %1, i64 %2, i64 %3, i64 %4, i64 %5, i64 %6, ptr %7, ptr %8, i64 %9, i64 %10, i64 %11, i64 %12, i64 %13) {
%15 = insertvalue { ptr, ptr, i64, [2 x i64], [2 x i64] } undef, ptr %0, 0
%16 = insertvalue { ptr, ptr, i64, [2 x i64], [2 x i64] } %15, ptr %1, 1
%17 = insertvalue { ptr, ptr, i64, [2 x i64], [2 x i64] } %16, i64 %2, 2
%18 = insertvalue { ptr, ptr, i64, [2 x i64], [2 x i64] } %17, i64 %3, 3, 0
%19 = insertvalue { ptr, ptr, i64, [2 x i64], [2 x i64] } %18, i64 %5, 4, 0
%20 = insertvalue { ptr, ptr, i64, [2 x i64], [2 x i64] } %19, i64 %4, 3, 1
%21 = insertvalue { ptr, ptr, i64, [2 x i64], [2 x i64] } %20, i64 %6, 4, 1
%22 = insertvalue { ptr, ptr, i64, [2 x i64], [2 x i64] } undef, ptr %7, 0
%23 = insertvalue { ptr, ptr, i64, [2 x i64], [2 x i64] } %22, ptr %8, 1
%24 = insertvalue { ptr, ptr, i64, [2 x i64], [2 x i64] } %23, i64 %9, 2
%25 = insertvalue { ptr, ptr, i64, [2 x i64], [2 x i64] } %24, i64 %10, 3, 0
%26 = insertvalue { ptr, ptr, i64, [2 x i64], [2 x i64] } %25, i64 %12, 4, 0
%27 = insertvalue { ptr, ptr, i64, [2 x i64], [2 x i64] } %26, i64 %11, 3, 1
%28 = insertvalue { ptr, ptr, i64, [2 x i64], [2 x i64] } %27, i64 %13, 4, 1
%29 = call ptr @malloc(i64 ptrtoint (ptr getelementptr (double, ptr null, i32 4) to i64))
%30 = insertvalue { ptr, ptr, i64, [2 x i64], [2 x i64] } undef, ptr %29, 0
%31 = insertvalue { ptr, ptr, i64, [2 x i64], [2 x i64] } %30, ptr %29, 1
%32 = insertvalue { ptr, ptr, i64, [2 x i64], [2 x i64] } %31, i64 0, 2
%33 = insertvalue { ptr, ptr, i64, [2 x i64], [2 x i64] } %32, i64 2, 3, 0
%34 = insertvalue { ptr, ptr, i64, [2 x i64], [2 x i64] } %33, i64 2, 3, 1
%35 = insertvalue { ptr, ptr, i64, [2 x i64], [2 x i64] } %34, i64 2, 4, 0
%36 = insertvalue { ptr, ptr, i64, [2 x i64], [2 x i64] } %35, i64 1, 4, 1
br label %37
37: ; preds = %60, %14
%38 = phi i64 [ %61, %60 ], [ 0, %14 ]
%39 = icmp slt i64 %38, 2
br i1 %39, label %40, label %62
40: ; preds = %37
br label %41
41: ; preds = %44, %40
%42 = phi i64 [ %59, %44 ], [ 0, %40 ]
%43 = icmp slt i64 %42, 2
br i1 %43, label %44, label %60
44: ; preds = %41
%45 = extractvalue { ptr, ptr, i64, [2 x i64], [2 x i64] } %21, 1
%46 = mul i64 %38, 2
%47 = add i64 %46, %42
%48 = getelementptr double, ptr %45, i64 %47
%49 = load double, ptr %48, align 8
%50 = extractvalue { ptr, ptr, i64, [2 x i64], [2 x i64] } %28, 1
%51 = mul i64 %38, 2
%52 = add i64 %51, %42
%53 = getelementptr double, ptr %50, i64 %52
%54 = load double, ptr %53, align 8
%55 = fadd double %49, %54
%56 = mul i64 %38, 2
%57 = add i64 %56, %42
%58 = getelementptr double, ptr %29, i64 %57
store double %55, ptr %58, align 8
%59 = add i64 %42, 1
br label %41
60: ; preds = %41
%61 = add i64 %38, 1
br label %37
62: ; preds = %37
ret { ptr, ptr, i64, [2 x i64], [2 x i64] } %36
}
!llvm.module.flags = !{!0}
!0 = !{i32 2, !"Debug Info Version", i32 3}
- The descriptors (the long run of
insertvalue). Each matrix arrives as seven separate arguments (ptr %0, ptr %1, i64 %2, …: allocated pointer, aligned pointer, offset, two sizes, two strides, Chapter 1's memref calling convention), and the first 14 instructions pack them into two{ ptr, ptr, i64, [2 x i64], [2 x i64] }values, the memref descriptors (seven more build the result's). Almost all of this is dead weight: only each argument's aligned pointer is read later (extractvalue … 1). It is the first thing the optimizer will delete. - The allocation:
call ptr @malloc(i64 ptrtoint (ptr getelementptr (double, ptr null, i32 4) to i64)), that ismalloc(4 * sizeof(double))for the 2 × 2 result (thegetelementptr … nullis the idiom forsizeof). The generated code never frees what it allocates. - The loops (
phi,icmp,br). LLVM IR is in static single assignment form: every name is assigned once. A loop counter that changes is therefore aphi:%38 = phi i64 [ %61, %60 ], [ 0, %14 ]means "if control came from block%60, take%61(the incremented value), if from%14, take 0". The two nested loops are the twophis,icmp sltthe bound test,br i1the conditional branch. - The body:
mul/addcompute the flat indexi * 2 + j,getelementptr double, ptr %45, i64 %47is the address of that element (LLVM's pointer arithmetic: it never reads memory),load double,fadd double %49, %54,store double. Thefaddcarries no flags: that detail matters twice below.
The 64 × 64 matrix product (02_matmul64.mg) has the same pieces plus a second loop nest for the zero fill and a three-deep nest whose body is load the running sum from the result, load a, load b, fmul, fadd, store the sum back (every iteration goes through memory).
What opt does, pass by pass¶
opt runs passes over the IR. walk.sh runs a growing list of the standard ones on the matrix product and counts what is left (instructions, basic blocks, and lines that mention a vector of doubles <N x double>). The target (-mtriple, -mcpu) must be given: without it opt does not know which vector instructions exist, and the vectorizer did nothing (the first version of this script forgot, and every row showed 0 vector lines).
== ijk order, 64 x 64 (opt-18, target x86-64, CPU sapphirerapids)
77 instructions, 16 blocks, 0 lines with vector doubles <- as emitted by mgc (no passes)
57 instructions, 12 blocks, 0 lines with vector doubles <- instcombine, simplifycfg
56 instructions, 11 blocks, 0 lines with vector doubles <- + loop-simplify, lcssa, loop-rotate
57 instructions, 11 blocks, 0 lines with vector doubles <- + licm (loop-invariant code motion)
57 instructions, 11 blocks, 0 lines with vector doubles <- + indvars
69 instructions, 15 blocks, 1 lines with vector doubles <- + loop-vectorize
56 instructions, 10 blocks, 1 lines with vector doubles <- + instcombine, simplifycfg again
180 instructions, 11 blocks, 32 lines with vector doubles <- + loop-unroll
102 instructions, 8 blocks, 0 lines with vector doubles <- the whole -O3 pipeline
== ikj order, 64 x 64 (opt-18, target x86-64, CPU sapphirerapids)
77 instructions, 16 blocks, 0 lines with vector doubles <- as emitted by mgc (no passes)
57 instructions, 12 blocks, 0 lines with vector doubles <- instcombine, simplifycfg
56 instructions, 11 blocks, 0 lines with vector doubles <- + loop-simplify, lcssa, loop-rotate
56 instructions, 11 blocks, 0 lines with vector doubles <- + licm (loop-invariant code motion)
56 instructions, 11 blocks, 0 lines with vector doubles <- + indvars
106 instructions, 20 blocks, 8 lines with vector doubles <- + loop-vectorize
85 instructions, 12 blocks, 8 lines with vector doubles <- + instcombine, simplifycfg again
377 instructions, 15 blocks, 144 lines with vector doubles <- + loop-unroll
169 instructions, 6 blocks, 98 lines with vector doubles <- the whole -O3 pipeline
instcombine,simplifycfg: 77 → 57 instructions. This is the dead descriptor packing going away, and constants folded.loop-rotate,licm,indvars: the loops are put in canonical form; the next table row shows no change in size, which is the point of canonical form: it is what the vectorizer needs.loop-vectorize: the ikj order grows from 56 to 106 instructions and gains vector arithmetic (the vector body, the scalar remainder loop, and the run-time checks that guard them). The ijk order grows to 69 instructions with one line mentioning vector doubles. Which line?store <4 x double> zeroinitializer: the zero-fill loop was vectorized, and the product's own loop was not (the last lines of the second excerpt below print it).loop-unroll: both orders unroll their inner loop (180 and 377 instructions).- the whole
-O3pipeline goes further than the list. The same file shows the ijk order's result:
== what the whole -O3 pipeline made of the ijk product's setup and inner loop (the lines that matter, from opt's own output)
9: %calloc = tail call dereferenceable_or_null(32768) ptr @calloc(i64 1, i64 32768)
15: %16 = getelementptr double, ptr %calloc, i64 %15
22: %.promoted = load double, ptr %18, align 8
27: %21 = phi double [ %.promoted, %.preheader ], [ %84, %19 ]
34: %27 = fmul double %24, %26
35: %28 = fadd double %21, %27
43: %35 = fmul double %32, %34
44: %36 = fadd double %28, %35
52: %43 = fmul double %40, %42
53: %44 = fadd double %36, %43
61: %51 = fmul double %48, %50
62: %52 = fadd double %44, %51
70: %59 = fmul double %56, %58
71: %60 = fadd double %52, %59
in all: 8 fadd, 8 fmul, 17 loads, 1 stores, 1 calloc, 0 malloc
== the one line with a vector of doubles that the ijk order has after loop-vectorize (before the loop-idiom pass that later turns the whole zero-fill loop into calloc)
store <4 x double> zeroinitializer, ptr %21, align 8
The malloc and the zero-fill loop became one calloc (the loop-idiom pass recognizes "allocate, then fill with zeros"). The running sum is no longer loaded and stored every iteration: %.promoted = load double before the loop, phi double [ %.promoted, … ] inside it, and a single store (17 loads and 1 store in the whole function): LICM's scalar promotion moved the accumulator into a register. The inner loop is unrolled eight times into a chain: %28 = fadd double %21, %27, then %36 = fadd double %28, %35, each addition waiting for the previous one. Remember this chain: it is the next section's subject, and the backend's.
Why the ijk loop is not vectorized, and what the permission costs¶
remarks.sh asks clang's loop vectorizer for its decisions (-Rpass, -Rpass-missed, -Rpass-analysis):
== ijk, clang -O3
loop not vectorized: cannot prove it is safe to reorder floating-point operations; allow reordering by specifying '#pragma clang loop vectorize(enable)' before the loop or by providing the compiler option '-ffast-math'.
loop not vectorized
== ijk, clang -O3 -march=sapphirerapids
loop not vectorized: cannot prove it is safe to reorder floating-point operations; allow reordering by specifying '#pragma clang loop vectorize(enable)' before the loop or by providing the compiler option '-ffast-math'.
loop not vectorized
== ikj, clang -O3
the cost-model indicates that interleaving is not beneficial
vectorized loop (vectorization width: 2, interleaved count: 1)
== ikj, clang -O3 -march=sapphirerapids
the cost-model indicates that interleaving is not beneficial
vectorized loop (vectorization width: 4, interleaved count: 1)
== (2) ijk with the reassoc flag on every fadd in the IR (sed 's/fadd double/fadd reassoc double/'), clang -O3 -march=sapphirerapids
fadd instructions changed: 1
the cost-model indicates that interleaving is not beneficial
vectorized loop (vectorization width: 4, interleaved count: 1)
== (3) ijk with clang's -ffast-math option on the unchanged .ll
loop not vectorized: cannot prove it is safe to reorder floating-point operations; allow reordering by specifying '#pragma clang loop vectorize(enable)' before the loop or by providing the compiler option '-ffast-math'.
loop not vectorized
assembly with and without -ffast-math: byte for byte identical
== the same sizes unknown until run time (examples/03_matmul_dynamic.mg), ijk and ikj, clang -O3 -march=sapphirerapids
-- ijk
loop not vectorized: cannot prove it is safe to reorder floating-point operations; allow reordering by specifying '#pragma clang loop vectorize(enable)' before the loop or by providing the compiler option '-ffast-math'.
loop not vectorized
-- ikj
vectorized loop (vectorization width: 4, interleaved count: 4)
For the default ijk order the vectorizer says, with and without -march=sapphirerapids (the CPU of the machine these pages were produced on; the scripts pin it, see "Is this specific to one CPU?" below):
loop not vectorized: cannot prove it is safe to reorder floating-point operations
For ikj it says vectorized loop (width 2 on plain x86-64, which has 128-bit SSE2 registers; width 4 with -march=sapphirerapids, which allows 256-bit AVX registers).
Why. The ijk inner loop is a reduction: sum = sum + a[i][k] * b[k][j]. A vectorized version adds four partial sums and combines them at the end, which adds the numbers in a different order, and floating-point addition is not associative: (a + b) + c can differ from a + (b + c). LLVM must produce exactly what the program says unless it is told reordering is allowed. The ikj loop has no reduction: each iteration c[i][j] += x * b[k][j] updates a different element, so the iterations are independent and vectorizing them reorders nothing.
The experiment: give the permission, and see what it buys. Fast-math permission is a flag on each instruction in the IR: fadd reassoc double … means "this addition may be reassociated". Add it to every fadd of the ijk IR (sed 's/fadd double/fadd reassoc double/'): the vectorizer now says vectorized loop (vectorization width: 4 …). The ijk loop vectorizes. Is it faster? variants.sh builds Chapter 24's benchmark (dynamic sizes) four ways and times each at N = 256 and 512 (pinned to one core, three passes, each result checked against a reference; the table is summarize_variants.py's):
variant N median GFLOP/s in each of the three passes slowest..fastest
ijk 256 2.17 2.01 2.52 2.01.. 2.52
ijk 512 0.63 0.74 0.64 0.63.. 0.74
ijk_reassoc 256 1.94 2.43 2.47 1.94.. 2.47
ijk_reassoc 512 0.63 0.62 0.66 0.62.. 0.66
ikj 256 11.73 9.23 9.94 9.23.. 11.73
ikj 512 7.16 7.34 7.36 7.16.. 7.36
ikj_reassoc 256 13.63 9.80 9.98 9.80.. 13.63
ikj_reassoc 512 8.01 8.34 6.84 6.84.. 8.34
ikj_contract 256 10.07 11.98 11.15 10.07.. 11.98
ikj_contract 512 9.38 8.24 9.55 8.24.. 9.55
mean of the three passes, relative to ijk at the same N:
ijk N=256 2.23 GFLOP/s = 1.00x ijk
ijk N=512 0.67 GFLOP/s = 1.00x ijk
ijk_reassoc N=256 2.28 GFLOP/s = 1.02x ijk
ijk_reassoc N=512 0.64 GFLOP/s = 0.95x ijk
ikj N=256 10.30 GFLOP/s = 4.61x ijk
ikj N=512 7.29 GFLOP/s = 10.88x ijk
ikj_reassoc N=256 11.14 GFLOP/s = 4.99x ijk
ikj_reassoc N=512 7.73 GFLOP/s = 11.54x ijk
ikj_contract N=256 11.07 GFLOP/s = 4.96x ijk
ikj_contract N=512 9.06 GFLOP/s = 13.52x ijk
- Vectorizing the ijk loop does not make it faster. Mean over three passes: 2.28 against 2.23 GFLOP/s at N = 256 and 0.64 against 0.67 at N = 512, with each pair's three passes overlapping. The vectorizer's permission was not what kept ijk slow.
- The loop order is.
ikjis 4.6× faster thanijkat N = 256 and 10.9× at N = 512, with or without the permission (whichikjdoes not need). - Why the vectorized ijk is no faster.
b[k][j]walks down a column: consecutivekare a whole row apart in memory, so the vector must be gathered one element at a time (the assembly of the vectorized ijk has 16vgatherinstructions; the ikj assembly has none:check_llvm_out.txt). Gathers move the same strided data a scalar loop would, so the reduction's permission buys no bandwidth. This is an inference from the instruction counts and the timings; no hardware counters were read (cache misses were not measured), so "memory-bound" is the explanation that fits, not a measured fact.
A correction. The first version of this experiment used clang's -ffast-math option, the way a C programmer would. It changed nothing: remarks.sh shows the same "cannot prove it is safe" remark and the assembly with and without the flag is byte for byte identical (cmp says so). The reason is that -ffast-math tells the front end which flags to put on the instructions it generates from C or C++ source; a .ll file already has its instructions, with no flags, and clang does not rewrite them. The permission has to be in the IR, which is where Mountain Goat would have to put it if it ever offered it (its lowering emits plain arith.addf; MLIR can attach fastmath<reassoc> to it, which was not done here).
Reading the assembly¶
Here are the two inner loops from clang -O3 -march=sapphirerapids (mca.py --loops), the ijk order's first, then ikj's:
## ijk (the default order): the innermost loop with the most multiply-adds (34 instructions)
vmovsd -56(%r8,%r13,8), %xmm1
vmovsd -48(%r8,%r13,8), %xmm2
vmulsd (%rcx), %xmm1, %xmm1
vaddsd %xmm1, %xmm0, %xmm0
leaq (%rcx,%rax,8), %r11
vmulsd (%rcx,%rax,8), %xmm2, %xmm1
vmovsd -40(%r8,%r13,8), %xmm2
vmulsd (%r10,%r11), %xmm2, %xmm2
vaddsd %xmm1, %xmm0, %xmm0
addq %r10, %r11
vaddsd %xmm2, %xmm0, %xmm0
vmovsd -32(%r8,%r13,8), %xmm1
vmulsd (%r10,%r11), %xmm1, %xmm1
addq %r10, %r11
vaddsd %xmm1, %xmm0, %xmm0
vmovsd -24(%r8,%r13,8), %xmm1
vmulsd (%r10,%r11), %xmm1, %xmm1
addq %r10, %r11
vaddsd %xmm1, %xmm0, %xmm0
vmovsd -16(%r8,%r13,8), %xmm1
vmulsd (%r10,%r11), %xmm1, %xmm1
addq %r10, %r11
vaddsd %xmm1, %xmm0, %xmm0
vmovsd -8(%r8,%r13,8), %xmm1
vmulsd (%r10,%r11), %xmm1, %xmm1
addq %r10, %r11
vaddsd %xmm1, %xmm0, %xmm0
vmovsd (%r8,%r13,8), %xmm1
vmulsd (%r10,%r11), %xmm1, %xmm1
vaddsd %xmm1, %xmm0, %xmm0
addq $8, %r13
addq %r9, %rcx
cmpq %r13, %rdi
jne .LBB0_9
## ikj: the innermost loop with the most multiply-adds (15 instructions)
vmulpd -96(%r9,%r12,8), %ymm1, %ymm2
vmulpd -64(%r9,%r12,8), %ymm1, %ymm3
vmulpd -32(%r9,%r12,8), %ymm1, %ymm4
vmulpd (%r9,%r12,8), %ymm1, %ymm5
vaddpd -96(%rdi,%r12,8), %ymm2, %ymm2
vaddpd -64(%rdi,%r12,8), %ymm3, %ymm3
vaddpd -32(%rdi,%r12,8), %ymm4, %ymm4
vaddpd (%rdi,%r12,8), %ymm5, %ymm5
vmovupd %ymm2, -96(%rdi,%r12,8)
vmovupd %ymm3, -64(%rdi,%r12,8)
vmovupd %ymm4, -32(%rdi,%r12,8)
vmovupd %ymm5, (%rdi,%r12,8)
addq $16, %r12
cmpq %r12, %rsi
jne .LBB0_11
- ijk (34 instructions, 8 multiply-adds): eight
vmulsd/vaddsdpairs (sd= scalar double). Everyvaddsdreads and writes%xmm0: the additions form a dependency chain, each waiting for the last, exactly the chain seen in the IR. The loads ofbstep by%r10(the row length) between multiplies: the column walk. There are no fused multiply-adds: thevmulsdandvaddsdare separate. - ikj (15 instructions, 16 multiply-adds): four
vmulpdand fourvaddpdon%ymmregisters (pd= packed double,ymm= 256 bits = 4 doubles), so 16 multiply-adds per iteration, with memory operands folded in and four stores. The four vectors are independent: no chain. (This CPU has 512-bit registers; clang 18 chose 256-bit ones. Why was not investigated.) - Neither uses
vfmadd. A fused multiply-add computesa * b + cin one instruction with one rounding, which gives a different result from a multiply followed by an add, so LLVM needs permission: thecontractflag on thefmuland thefadd. C compilers grant it by default in C (-ffp-contract=on), which is why you rarely see this; our IR has no flags. Addingcontract(samesedtrick) turns the ikj loop's multiply and add intovfmaddinstructions (16 in the static 64 × 64 version,check_llvm_out.txt), andvariants.shmeasures them: ikj withcontractis 7.3 → 9.1 GFLOP/s at N = 512 (all three of its passes, 8.24 to 9.55, are above all three of plain ikj's, 7.16 to 7.36) and 10.3 → 11.1 at N = 256 (overlapping passes: no clear gain there). That is about +24% at N = 512 on this machine.
What llvm-mca predicts, and where the prediction fails¶
llvm-mca takes a block of assembly and simulates how the CPU's pipeline would execute it, many times, and reports cycles. It models the core (instruction latencies, issue ports, dispatch width). It assumes every load hits the L1 cache: it does not model the memory system. mca.py runs each variant's hot loop through it (-mcpu=sapphirerapids) and divides by the multiply-adds per iteration:
llvm-mca-18, -mcpu=sapphirerapids, 100 iterations of the loop body; dynamic sizes (examples/03_matmul_dynamic.mg), clang -O3 -march=sapphirerapids
variant instrs mul-adds cycles/iter cycles per mul-add
ijk (the default order) 34 8 24.1 3.02
ijk, reassoc on every fadd 15 16 3.2 0.20
ikj 15 16 4.6 0.29
ikj, contract on every fmul and fadd 15 16 3.9 0.25
- The ijk loop costs 3.02 cycles per multiply-add: the chain of eight dependent additions is the limit. The ikj loop costs 0.29: ten times less.
reassocis predicted to be a big win for ijk: 0.20 cycles per multiply-add, 15 times better. The model sees the chain broken into independent vector adds. The measurement did not show it (previous section: 0.64 against 0.67 GFLOP/s). The model and the measurement disagree, and the disagreement is informative:llvm-mcais blind to the strided column walk, which is the bottleneck. This is the page's cleanest example of what a core model can and cannot say: it correctly ranks ikj above ijk (predicted 10.4×, measured 4.6× at N = 256 and 10.9× at N = 512), and it incorrectly predicts a large gain from vectorizing ijk.contractis predicted to help ikj a little (0.29 → 0.25 cycles per multiply-add, 14%): the same direction as the measurement, at a smaller size than the +24% measured at N = 512.
(One repair along the way: the first version of mca.py picked a loop that contained the inner loops, so it counted too many multiplies; and weighed instructions, not lanes, so it preferred the unrolled scalar loop to the vectorized one. It now takes innermost loops only, ranked by multiply-adds per iteration.)
Which Mountain Goat operations vectorize?¶
ops_table.sh compiles each one-operation program in ops/ on 64 × 64 matrices with clang -O3 -march=sapphirerapids and counts, in the assembly, packed arithmetic instructions (vaddpd, vmulpd, vmaxpd, vsqrtpd, vcmppd, …: four doubles at once), scalar ones (vaddsd, …), gathers, and library calls. It counts instructions; it says whether the compiler vectorized, not whether that was faster.
operation packed scalar gather library calls
add 16 0 0
hadamard 16 0 0
scale 16 0 0
relu 32 0 0
sqrt 16 0 0
ge 16 0 0
bcast_add 16 0 0 memcpy
transpose 0 0 16
col_sum 16 0 0
row_sum 64 0 64
row_mean 80 0 64
row_max 128 0 64
exp 0 0 0 exp
log 0 0 0 log
- The element-wise operations (
add,hadamard(a * b),scale,relu,sqrt,ge, and the broadcast add) are all vectorized with no scalar arithmetic left. (relu's count is 32 against 16 for the others; its instruction mix was not examined.bcast_addalso callsmemcpy.) expandlogare not vectorized: no packed arithmetic at all, and the assembly calls the C library'sexpandlogonce per element (clang has no vector version of them to call). Softmax (Chapter 29), and so every attention head in Chapters 30 to 36, usesexp.transposehas no arithmetic: it is 16 gathers and moves.- The row reductions (
row_sum,row_mean,row_max) have no scalar adds and use 64 gathers each, which is the opposite of what the matrix product's reduction did. The reason is not established here: the assembly (64 gathers feeding 64 packed adds, no scalar adds) is consistent with LLVM keeping each row's addition order exactly as written and vectorizing across rows (four rows' running sums in one register, gathered from four rows), which reorders nothing, but the transformation was not identified (the IR was not read for it). The same permission question does not arise when each lane is a different row.col_sumadds whole rows, so it vectorizes without gathers.
Is this specific to one CPU? Yes, in the details¶
The first CI run of this chapter's test failed, on a different CPU from the one used here, while every other test passed. The compiler's choices depend on the target CPU, so the scripts now pin it (sapphirerapids; CPU=native ./walk.sh overrides) and the chapter's claims are claims about that target. cpu_dependence.py compiles the same IR for four CPUs (compilation only: nothing is run) and shows how much changes:
same IR, compiled for four x86-64 CPUs (compile only; clang -O3 -march=CPU, opt -mcpu=CPU, llvm-mca -mcpu=CPU)
sapphirerapids skylake-avx512 haswell znver3
ijk not vectorized not vectorized not vectorized not vectorized
ikj vectorized (width 4) vectorized (width 4) vectorized (width 4) vectorized (width 4)
ijk + reassoc vectorized (width 4) vectorized (width 4) vectorized (width 4) vectorized (width 4)
vgather in ijk + reassoc 16 16 0 0
vgather in row_sum 64 64 0 0
vector fmul left in ijk after -O3 0 0 0 0
vfmadd in ikj + contract 16 16 16 64
mca cycles/mul-add: ijk, ijk+reassoc, ikj 3.02 , 0.20 , 0.29 4.02 , 0.26 , 0.30 3.02 , 0.32 , 0.45 3.02 , 0.26 , 0.38
- What does not depend on the CPU, in these four:
ijkis not vectorized,ikjis (width 4), andijkwith thereassocflag is (width 4). The reduction story holds everywhere tried. - What does: the gathers. On
sapphirerapidsandskylake-avx512the vectorized ijk loop androw_sumusevgatherinstructions (16 and 64). Onhaswellandznver3there are none, although both CPUs have AVX2 and the vectorizer still vectorizes: it fills the vectors with scalar loads instead (LLVM treats gathers as slow on those CPUs; the exact mechanism in the assembly was not read for them). So the chapter's inference "the vectorized ijk is no faster because it gathers" was tested only on the Intel targets and the measurement was made on an Intel machine; for the other two CPUs only the instruction mix, not a timing, is known. - The fused multiply-add count differs (16, 16, 16 and 64
vfmaddfor the same loop), and so dollvm-mca's cycles per multiply-add (ijk 3.0 to 4.0, ijk withreassoc0.20 to 0.32, ikj 0.29 to 0.45): the ranking is the same on all four (ikj and reassoc'd ijk far below ijk), the numbers are not.
Tests¶
Two new lit files in test/llvm37/ (the suite is now 146 tests; no verifiers or ir test because no compiler operation was added):
| Test | What it checks |
|---|---|
examples |
the three programs compile to the expected mg operation; every one of the fourteen one-operation programs in ops/ compiles |
claims |
check_llvm.py, below (about 3 seconds) |
check_llvm.py runs the tools and tests the claims, in five groups: (1) the IR's shape (one fadd, one malloc, two loop counters for the add; a fmul and a fadd in the product); (2) opt: after loop-vectorize ikj has a vector fmul and ijk has only the vector store of zeros; the whole -O3 pipeline leaves one calloc, no malloc, a promoted accumulator and no vector multiply; (3) the remarks: ijk's "cannot prove it is safe", ikj's "vectorized loop", ijk with reassoc vectorized and gathering (16 vgathers, ikj none), and -ffast-math leaving the assembly identical; (4) the operations: add packed and not scalar, exp a library call and not packed, row_sum gathers, transpose no arithmetic; (5) the backend: ikj has no vfmadd and has them with contract, and llvm-mca puts ijk above five times ikj per multiply-add and reassoc below a third of ijk.
-- 1. reading the IR
ok the 2x2 add: one fadd, one malloc, two loop counters (phi i64)
ok the 64x64 product (ijk): a fmul and a fadd, five loop counters (two for the zero fill, three for the product)
-- 2. the passes (opt)
ok after loop-vectorize the ikj order has a vector fmul
ok after loop-vectorize the ijk order has no vector fmul, only the vector store of zeros
ok the whole -O3 pipeline turns ijk's malloc and zero-fill loop into one calloc
ok the accumulator lives in a register (phi double, promoted from memory) and no multiply is a vector one
-- 3. the vectorizer's reasons (clang remarks)
ok ijk: not vectorized, 'cannot prove it is safe to reorder floating-point operations'
ok ikj: 'vectorized loop'
ok ijk with the reassoc flag on its fadd: 'vectorized loop'
ok the vectorized ijk gathers the column of b with vgather instructions (16); ikj needs none (0)
ok clang's -ffast-math on the unchanged .ll leaves the assembly byte for byte identical
-- 4. the operations (packed = vector arithmetic, scalar = one double at a time)
ok add: packed arithmetic (16), no scalar arithmetic (0)
ok exp: no packed arithmetic, a library call to ['exp']
ok row_sum: packed arithmetic (64) fed by gathers (64)
ok transpose: no arithmetic at all (0 packed), only moves
-- 5. the backend
ok ikj: no fused multiply-add (0); with the contract flag: 16
ok llvm-mca: ijk 3.02 cycles per multiply-add against ikj 0.29 (more than 5x)
ok llvm-mca: ijk with reassoc 0.2 against ijk 3.02 (less than a third)
all checks pass
Are the checks good enough? Feed it six wrong inputs.¶
claims_mutation.py changes one input at a time (the environment variables CHK_* of the checker) and expects the checker to fail:
NOTE: this script deliberately breaks inputs. 'caught' lines are EXPECTED: they show each check can fail.
baseline (nothing broken): checker passes (expected)
the 'ijk' program is built in ikj order: caught by 4 failing check(s):
after loop-vectorize the ijk order has no vector fmul, only the vector store of zeros
the accumulator lives in a register (phi double, promoted from memory) and no multiply is a vector one
ijk: not vectorized, 'cannot prove it is safe to reorder floating-point operations'
the vectorized ijk gathers the column of b with vgather instructions (0); ikj needs none (0)
the reassoc flag is not applied (the edit changes nothing): caught by 2 failing check(s):
ijk with the reassoc flag on its fadd: 'vectorized loop'
the vectorized ijk gathers the column of b with vgather instructions (0); ikj needs none (0)
the second assembly is built with -O1 in place of -ffast-math (a flag that does matter): caught by 1 failing check(s):
clang's -O1 on the unchanged .ll leaves the assembly byte for byte identical
no -march=native (plain x86-64: SSE2 instructions, not the v-prefixed AVX ones the checks count): caught by 5 failing check(s):
ijk with the reassoc flag on its fadd: 'vectorized loop'
the vectorized ijk gathers the column of b with vgather instructions (0); ikj needs none (0)
add: packed arithmetic (0), no scalar arithmetic (0)
row_sum: packed arithmetic (0) fed by gathers (0)
ikj: no fused multiply-add (0); with the contract flag: 0
the contract flag is replaced by nsz (a flag that does not allow fusing): caught by 1 failing check(s):
ikj: no fused multiply-add (0); with the contract flag: 0
opt is run without a target (no -mtriple, no -mcpu): caught by 2 failing check(s):
after loop-vectorize the ikj order has a vector fmul
after loop-vectorize the ijk order has no vector fmul, only the vector store of zeros
All six are caught, each by the checks that should depend on the input that was broken: the ijk program built in the other order fails the three ijk claims; leaving out reassoc fails the "vectorized" claim and the gather claim; replacing -ffast-math with -O1 (a flag that does change the assembly) fails the byte-for-byte claim; nsz in place of contract fails the fused-multiply-add claim; opt without a target fails both vectorization claims. One result was a surprise and is worth reading: removing AVX (-mno-avx) fails five checks, including "ijk with reassoc is vectorized": with only SSE2 the vectorizer declines the strided reduction loop that it vectorizes with AVX (the reason was not investigated; the cost model or the missing gather instructions are the candidates). The practical lesson is that these claims describe the sapphirerapids target (the checks pin sapphirerapids), and would need different expected values for other targets, as the CPU section shows.
The full suite:
-- Testing: 146 tests, 4 workers --
PASS: IR Forge / Mountain Goat :: lm35/against-reference.mlir (1 of 146)
PASS: IR Forge / Mountain Goat :: lm36/examples.mlir (2 of 146)
PASS: IR Forge / Mountain Goat :: attention33/against-reference.mlir (3 of 146)
PASS: IR Forge / Mountain Goat :: lm36/against-reference.mlir (4 of 146)
PASS: IR Forge / Mountain Goat :: generalize32/against-reference.mlir (5 of 146)
PASS: IR Forge / Mountain Goat :: attention33/examples.mlir (6 of 146)
PASS: IR Forge / Mountain Goat :: transformer30/against-reference.mlir (7 of 146)
PASS: IR Forge / Mountain Goat :: lm35/examples.mlir (8 of 146)
PASS: IR Forge / Mountain Goat :: contraction28/against-definition.mlir (9 of 146)
PASS: IR Forge / Mountain Goat :: contraction/tensor-contraction.mlir (10 of 146)
PASS: IR Forge / Mountain Goat :: attention29/against-reference.mlir (11 of 146)
PASS: IR Forge / Mountain Goat :: generalize32/examples.mlir (12 of 146)
PASS: IR Forge / Mountain Goat :: llvm37/claims.mlir (13 of 146)
PASS: IR Forge / Mountain Goat :: tour/gallery.mlir (14 of 146)
PASS: IR Forge / Mountain Goat :: learning/gradient-descent.mlir (15 of 146)
PASS: IR Forge / Mountain Goat :: bigram31/against-reference.mlir (16 of 146)
PASS: IR Forge / Mountain Goat :: contraction28/examples.mlir (17 of 146)
PASS: IR Forge / Mountain Goat :: tour/tour-examples.mlir (18 of 146)
PASS: IR Forge / Mountain Goat :: attention29/examples.mlir (19 of 146)
PASS: IR Forge / Mountain Goat :: tour/more-examples.mlir (20 of 146)
PASS: IR Forge / Mountain Goat :: loop-order/results-identical.mlir (21 of 146)
PASS: IR Forge / Mountain Goat :: perf/options-same-results.mlir (22 of 146)
PASS: IR Forge / Mountain Goat :: transformer30/examples.mlir (23 of 146)
PASS: IR Forge / Mountain Goat :: loop-order/bitwise-identical.mlir (24 of 146)
PASS: IR Forge / Mountain Goat :: broadcast/values.mlir (25 of 146)
PASS: IR Forge / Mountain Goat :: bigram31/examples.mlir (26 of 146)
PASS: IR Forge / Mountain Goat :: contraction/mismatch-trap.mlir (27 of 146)
PASS: IR Forge / Mountain Goat :: ops/values.mlir (28 of 146)
PASS: IR Forge / Mountain Goat :: ops/elementwise-mismatch-aborts.mlir (29 of 146)
PASS: IR Forge / Mountain Goat :: frontend/cpp-interop.mlir (30 of 146)
PASS: IR Forge / Mountain Goat :: llvm37/examples.mlir (31 of 146)
PASS: IR Forge / Mountain Goat :: broadcast/cpp-interop-reductions.mlir (32 of 146)
PASS: IR Forge / Mountain Goat :: ops/cpp-interop-ops.mlir (33 of 146)
PASS: IR Forge / Mountain Goat :: frontend/more-examples.mlir (34 of 146)
PASS: IR Forge / Mountain Goat :: loop-order/cpp-ikj.mlir (35 of 146)
PASS: IR Forge / Mountain Goat :: broadcast/networks.mlir (36 of 146)
PASS: IR Forge / Mountain Goat :: frontend/dynamic-runs.mlir (37 of 146)
PASS: IR Forge / Mountain Goat :: perf/flags-reach-clang.mlir (38 of 146)
PASS: IR Forge / Mountain Goat :: broadcast/identities.mlir (39 of 146)
PASS: IR Forge / Mountain Goat :: tour/scientific-literals.mlir (40 of 146)
PASS: IR Forge / Mountain Goat :: ops/dynamic-ops.mlir (41 of 146)
PASS: IR Forge / Mountain Goat :: frontend/runtime-mismatch-aborts.mlir (42 of 146)
PASS: IR Forge / Mountain Goat :: perf/tiling-structure.mlir (43 of 146)
PASS: IR Forge / Mountain Goat :: broadcast/dynamic-reductions.mlir (44 of 146)
PASS: IR Forge / Mountain Goat :: execution/gpu-host-with-stub-runtime.mlir (45 of 146)
PASS: IR Forge / Mountain Goat :: execution/dynamic-shapes-run.mlir (46 of 146)
PASS: IR Forge / Mountain Goat :: ops/precedence-and-identities.mlir (47 of 146)
PASS: IR Forge / Mountain Goat :: ops/composed-programs.mlir (48 of 146)
PASS: IR Forge / Mountain Goat :: ops/negate-and-dynamic-divide.mlir (49 of 146)
PASS: IR Forge / Mountain Goat :: frontend/hello-and-static.mlir (50 of 146)
PASS: IR Forge / Mountain Goat :: loop-order/flag-reaches-pass.mlir (51 of 146)
PASS: IR Forge / Mountain Goat :: contraction28/errors.mlir (52 of 146)
PASS: IR Forge / Mountain Goat :: execution/mismatch-aborts.mlir (53 of 146)
PASS: IR Forge / Mountain Goat :: execution/mismatch-columns-aborts.mlir (54 of 146)
PASS: IR Forge / Mountain Goat :: ops/ptx-new-ops.mlir (55 of 146)
PASS: IR Forge / Mountain Goat :: tour/tour-errors.mlir (56 of 146)
PASS: IR Forge / Mountain Goat :: execution/static-add-runs.mlir (57 of 146)
PASS: IR Forge / Mountain Goat :: assert-stderr/message-on-stderr.mlir (58 of 146)
PASS: IR Forge / Mountain Goat :: chapters/ch09-execution-engine.mlir (59 of 146)
PASS: IR Forge / Mountain Goat :: ops/matmul-mismatch-aborts.mlir (60 of 146)
PASS: IR Forge / Mountain Goat :: execution/strided-view.mlir (61 of 146)
PASS: IR Forge / Mountain Goat :: transformer30/errors.mlir (62 of 146)
PASS: IR Forge / Mountain Goat :: dynamic-loops/tiling-runs.mlir (63 of 146)
PASS: IR Forge / Mountain Goat :: dynamic-loops/unroll-runs.mlir (64 of 146)
PASS: IR Forge / Mountain Goat :: ops/division-ieee.mlir (65 of 146)
PASS: IR Forge / Mountain Goat :: dynamic-loops/runtime-check-survives-transforms.mlir (66 of 146)
PASS: IR Forge / Mountain Goat :: attention29/errors.mlir (67 of 146)
PASS: IR Forge / Mountain Goat :: dynamic-loops/fusion-after-tiling.mlir (68 of 146)
PASS: IR Forge / Mountain Goat :: lm35/errors.mlir (69 of 146)
PASS: IR Forge / Mountain Goat :: broadcast/ptx-layer.mlir (70 of 146)
PASS: IR Forge / Mountain Goat :: assert-stderr/nested-assert-runs.mlir (71 of 146)
PASS: IR Forge / Mountain Goat :: bigram31/errors.mlir (72 of 146)
PASS: IR Forge / Mountain Goat :: execution/mixed-static-dynamic.mlir (73 of 146)
PASS: IR Forge / Mountain Goat :: attention33/errors.mlir (74 of 146)
PASS: IR Forge / Mountain Goat :: frontend/ptx-from-source.mlir (75 of 146)
PASS: IR Forge / Mountain Goat :: chapters/ch01-pipeline.mlir (76 of 146)
PASS: IR Forge / Mountain Goat :: contraction28/ir.mlir (77 of 146)
PASS: IR Forge / Mountain Goat :: assert-stderr/message-bytes-exact.mlir (78 of 146)
PASS: IR Forge / Mountain Goat :: ffn34/errors.mlir (79 of 146)
PASS: IR Forge / Mountain Goat :: assert-stderr/valid-shapes-still-pass.mlir (80 of 146)
PASS: IR Forge / Mountain Goat :: dynamic-loops/fusion-needs-constant-trip-counts.mlir (81 of 146)
PASS: IR Forge / Mountain Goat :: generalize32/errors.mlir (82 of 146)
PASS: IR Forge / Mountain Goat :: gpu/pass-order-crash.mlir (83 of 146)
PASS: IR Forge / Mountain Goat :: lm36/errors.mlir (84 of 146)
PASS: IR Forge / Mountain Goat :: dynamic-loops/unroll-full-needs-constant-trip-count.mlir (85 of 146)
PASS: IR Forge / Mountain Goat :: gpu/ptx-default.mlir (86 of 146)
PASS: IR Forge / Mountain Goat :: gpu/ptx-index32.mlir (87 of 146)
PASS: IR Forge / Mountain Goat :: loop-order/structure.mlir (88 of 146)
PASS: IR Forge / Mountain Goat :: frontend/front-end-errors.mlir (89 of 146)
PASS: IR Forge / Mountain Goat :: gpu/host-runtime-calls-bare.mlir (90 of 146)
PASS: IR Forge / Mountain Goat :: gpu/ptx-bare-pointers.mlir (91 of 146)
PASS: IR Forge / Mountain Goat :: gpu/host-runtime-calls.mlir (92 of 146)
PASS: IR Forge / Mountain Goat :: attention29/ir.mlir (93 of 146)
PASS: IR Forge / Mountain Goat :: transformer30/ir.mlir (94 of 146)
PASS: IR Forge / Mountain Goat :: gpu/nvvm-lowering.mlir (95 of 146)
PASS: IR Forge / Mountain Goat :: bigram31/ir.mlir (96 of 146)
PASS: IR Forge / Mountain Goat :: chapters/ch02-example-programs.mlir (97 of 146)
PASS: IR Forge / Mountain Goat :: ops/front-end-errors.mlir (98 of 146)
PASS: IR Forge / Mountain Goat :: broadcast/roundtrip-and-lowering.mlir (99 of 146)
PASS: IR Forge / Mountain Goat :: broadcast/front-end-errors.mlir (100 of 146)
PASS: IR Forge / Mountain Goat :: ops/matmul-lowering-structure.mlir (101 of 146)
PASS: IR Forge / Mountain Goat :: assert-stderr/guard-nested-assert.mlir (102 of 146)
PASS: IR Forge / Mountain Goat :: assert-stderr/static-unchanged.mlir (103 of 146)
PASS: IR Forge / Mountain Goat :: gpu/kernel-outlining.mlir (104 of 146)
PASS: IR Forge / Mountain Goat :: frontend/emit-mlir.mlir (105 of 146)
PASS: IR Forge / Mountain Goat :: dynamic-loops/unroll-epilogue.mlir (106 of 146)
PASS: IR Forge / Mountain Goat :: dynamic-loops/fusion-fires-when-static.mlir (107 of 146)
PASS: IR Forge / Mountain Goat :: generalize32/ir.mlir (108 of 146)
PASS: IR Forge / Mountain Goat :: dynamic-loops/fusion-declines.mlir (109 of 146)
PASS: IR Forge / Mountain Goat :: gpu/cuda-comparison.mlir (110 of 146)
PASS: IR Forge / Mountain Goat :: loops/tiling.mlir (111 of 146)
PASS: IR Forge / Mountain Goat :: dynamic-loops/tiling-structure.mlir (112 of 146)
PASS: IR Forge / Mountain Goat :: ops/elementwise-static-mismatch.mlir (113 of 146)
PASS: IR Forge / Mountain Goat :: verifier/transpose-bad-shape.mlir (114 of 146)
PASS: IR Forge / Mountain Goat :: canonicalize/transpose-twice.mlir (115 of 146)
PASS: IR Forge / Mountain Goat :: assert-stderr/run-twice-no-collision.mlir (116 of 146)
PASS: IR Forge / Mountain Goat :: loops/fusion.mlir (117 of 146)
PASS: IR Forge / Mountain Goat :: loops/unroll-full.mlir (118 of 146)
PASS: IR Forge / Mountain Goat :: broadcast/verifier-broadcast.mlir (119 of 146)
PASS: IR Forge / Mountain Goat :: verifier/dynamic-accepted.mlir (120 of 146)
PASS: IR Forge / Mountain Goat :: loop-order/bad-option.mlir (121 of 146)
PASS: IR Forge / Mountain Goat :: ops/matmul-inner-mismatch.mlir (122 of 146)
PASS: IR Forge / Mountain Goat :: verifier/transpose-rank3.mlir (123 of 146)
PASS: IR Forge / Mountain Goat :: assert-stderr/rewrite-structure.mlir (124 of 146)
PASS: IR Forge / Mountain Goat :: verifier/unranked-rejected.mlir (125 of 146)
PASS: IR Forge / Mountain Goat :: ops/matmul-bad-result-shape.mlir (126 of 146)
PASS: IR Forge / Mountain Goat :: broadcast/verifier-reduce.mlir (127 of 146)
PASS: IR Forge / Mountain Goat :: lowering/bufferize-static-add.mlir (128 of 146)
PASS: IR Forge / Mountain Goat :: generalize32/verifiers.mlir (129 of 146)
PASS: IR Forge / Mountain Goat :: verifier/add-static-mismatch.mlir (130 of 146)
PASS: IR Forge / Mountain Goat :: lowering/bufferize-dynamic-add.mlir (131 of 146)
PASS: IR Forge / Mountain Goat :: ops/scalar-bad-op.mlir (132 of 146)
PASS: IR Forge / Mountain Goat :: lowering/dynamic-add-affine.mlir (133 of 146)
PASS: IR Forge / Mountain Goat :: lowering/mixed-add-one-check.mlir (134 of 146)
PASS: IR Forge / Mountain Goat :: canonicalize/fold-constant-add.mlir (135 of 146)
PASS: IR Forge / Mountain Goat :: canonicalize/transpose-twice-dynamic-exact.mlir (136 of 146)
PASS: IR Forge / Mountain Goat :: canonicalize/transpose-twice-dynamic-mixed.mlir (137 of 146)
PASS: IR Forge / Mountain Goat :: ops/dynamic-accepted.mlir (138 of 146)
PASS: IR Forge / Mountain Goat :: lowering/static-add-affine.mlir (139 of 146)
PASS: IR Forge / Mountain Goat :: contraction28/verifiers.mlir (140 of 146)
PASS: IR Forge / Mountain Goat :: lowering/dynamic-transpose-no-check.mlir (141 of 146)
PASS: IR Forge / Mountain Goat :: transformer30/verifiers.mlir (142 of 146)
PASS: IR Forge / Mountain Goat :: attention29/verifiers.mlir (143 of 146)
PASS: IR Forge / Mountain Goat :: bigram31/verifiers.mlir (144 of 146)
PASS: IR Forge / Mountain Goat :: ffn34/examples.mlir (145 of 146)
PASS: IR Forge / Mountain Goat :: ffn34/against-reference.mlir (146 of 146)
Testing Time: 98.73s
Total Discovered Tests: 146
Passed: 146 (100.00%)
Limits and what is not established¶
- One machine, one LLVM. An Intel Xeon (
sapphirerapidsin LLVM's naming) at 2.1 GHz, LLVM 18.1.3, clang 18.1.3. Vector widths, the choice of 256-bit registers,llvm-mca's numbers and the benchmark all depend on them. The checks pinsapphirerapids; the CPU section shows that gathers, fused multiply-adds andllvm-mcanumbers differ on other targets. - "Memory-bound" is inferred, not measured. The explanation for the vectorized ijk being no faster is gathers in the assembly plus unchanged timings; no hardware counters (cache misses, TLB misses) were read.
- The benchmark is the Chapter 24 harness with its noise. Three passes, one pinned core, spreads of up to about 30% between passes at N = 256 (for example ikj's 9.2 to 11.7 GFLOP/s); the contract result at N = 512 is consistent across passes, the one at N = 256 is not.
llvm-mcais a core model. It ignores the memory system and assumes perfect branch prediction; the page shows it ranking correctly and over-predicting the gain from vectorizing ijk.-ffast-mathwas shown to be a no-op on.llfiles, but Mountain Goat does not generate the flags. Addingreassocorcontractto the generated IR was done by text substitution in the experiments; it was not added to the compiler, and the results of such programs would no longer be bit-for-bit those of Chapter 24's loop order (reassocandcontractchange rounding).- The row reductions' mechanism is not identified (see the operations section).
- Only the matrix product and fourteen single operations were looked at, at 64 × 64; the training programs of Chapters 35 and 36, whose compile time was long, were not studied (where
mg-optspends it is not known). - No custom LLVM pass was written, and the AVX-512 question (why 256-bit registers) was not pursued.
- Platform: x86-64 Linux, LLVM 18.1.3.
Reproducing¶
cd docs/part32/code && ./build.sh
cd ../../part37/code
./stages.sh > stages_out.txt && ./walk.sh > walk_out.txt && ./remarks.sh > remarks_out.txt && ./ops_table.sh > ops_table_out.txt
./mca.py > mca_out.txt && ./mca.py --loops > loops_out.txt
./variants.sh > variants_out.txt && ./summarize_variants.py > variants_summary.txt
./check_llvm.py > check_llvm_out.txt && ./claims_mutation.py > claims_mutation_out.txt
cd ../../part15/code && ./run_lit.sh # 146 tests
Chapter summary¶
- After MLIR,
mgcwrites a.llfile (LLVM IR in SSA form:phifor loop counters,getelementptrfor addresses,fadd doublewith no flags).optthen deletes the memref-descriptor packing, turnsmallocplus a zero-fill loop intocalloc, keeps the matrix product's running sum in a register, and unrolls the inner loop. - The default
ijkloop is not vectorized because it is a floating-point reduction and LLVM "cannot prove it is safe to reorder floating-point operations". Giving it permission (thereassocflag in the IR) makes it vectorize, and it gets no faster: the vectorized loop gathers the strided column ofb. The loop order, not the vectorizer's permission, is what separates the two (4.6× at N = 256, 10.9× at N = 512). llvm-mcacorrectly predicts that the ijk chain costs about ten times more than the ikj loop per multiply-add, and incorrectly predicts a 15× gain from vectorizing ijk: it models the core, not the memory.- clang's
-ffast-mathdoes nothing to a.llfile (the assembly is byte for byte identical); the flags must be in the IR. Thecontractflag turns ikj's multiply and add into fused multiply-adds, +24% at N = 512 on this machine and no clear gain at N = 256. - Element-wise operations vectorize;
expandlogdo not (library calls); the row reductions vectorize across rows with gathers. - Eighteen checks pass; six wrong inputs were caught, and one of them showed that the vectorizer declines the reassociated ijk loop without AVX.
Self-check questions¶
Each answer is collapsed; try the question first.
-
Why does LLVM refuse to vectorize
sum = sum + a * bbut vectorizec[j] = c[j] + x * b[j]?Answer
The first is a reduction: vectorizing it computes several partial sums and adds them at the end, in a different order than the program wrote, and floating-point addition is not associative, so the result could differ in the last digits. LLVM keeps exactly the program's order unless a
reassocflag allows otherwise. The second updates a different element each iteration, so the iterations are independent and doing four at once changes no individual result. -
The
faddin the IR has no flags. Where would a C programmer have gotten them, and why did-ffast-mathdo nothing here?Answer
When clang compiles C or C++ source,
-ffast-mathmakes the front end put fast-math flags (reassoc,nnan, …) on the instructions it generates.mgcgenerates its instructions through MLIR, with no flags, and then clang is handed a finished.llfile; clang's optimizer reads the flags that are there and does not add any. The assembly with and without the option was byte for byte identical. The flag has to be on the instruction in the IR (fadd reassoc double, orfastmath<reassoc>in MLIR). -
With
reassocthe ijk loop was vectorized. Why was it not faster?Answer
The loop reads
bdown a column, one element per row, so a vector of four consecutivekvalues has to be gathered from four different rows (the assembly has 16vgatherinstructions where ikj has none). The vectorized loop moves the same scattered data a scalar loop would. The timings (0.64 against 0.67 GFLOP/s at N = 512) say the arithmetic was not the bottleneck. This is an inference from the instruction counts and the timings; cache misses were not measured. -
What does
llvm-mcamodel, and what did the experiment show about its limits?Answer
It models the CPU core: instruction latencies, issue ports and dispatch width, with every load assumed to hit the L1 cache. It ranked ikj above ijk correctly (0.29 against 3.02 cycles per multiply-add) but predicted that vectorizing ijk would be 15 times better (0.20), which the measurement contradicted, because the strided column walk (the real limit) is a memory-system effect it does not simulate.
-
What does the
contractflag allow, and why does LLVM need to be told?Answer
It allows a
fmulfollowed by afaddto be fused into one fused multiply-add (vfmadd), which rounds once instead of twice and so can give a slightly different result. Without the flag LLVM must keep two roundings. C compilers grant it by default for C source (-ffp-contract=on), which is why it is rarely noticed; a.llfile generated from MLIR has no flags. With the flag the ikj loop becamevfmaddinstructions and ran about 24% faster at N = 512 (and showed no clear gain at N = 256). -
In
ops_table_out.txt,exphas no packed arithmetic. What does the assembly do instead, and why does that matter for the transformer chapters?Answer
It calls the C library's
exponce per element: LLVM has no vector version of it to use. Softmax in every attention head (Chapters 29 to 36) computesexpover the whole score matrix (112 × 112 per head in Chapters 35 and 36), so those calls are a large part of the work that the matrix products' loop-order tuning does not touch. This chapter did not measure how large. -
The chapter's first
optexperiment showed 0 vector lines for every pass. What was wrong?Answer
optwas run without a target (-mtriple,-mcpu), so it had no description of the machine's vector instructions and the vectorizer had nothing to vectorize with. With-mtriple=x86_64-unknown-linux-gnu -mcpu=nativethe same passes vectorize. One of the six wrong inputs inclaims_mutation.pyreproduces this on purpose, and the checker catches it.