Appendix C. Introduction to LLVM IR and x86-64 Assembly¶
The last stage of every compiler in this book is LLVM: it takes the LLVM IR text that MLIR prints (Appendix B, Stage 4) and turns it into machine code. Chapter 37 reads that stage closely; this appendix is the short reading guide you would want before it. One small C function is followed through three forms (unoptimised IR, optimised IR, x86-64 assembly), and every instruction that appears is explained. Everything shown was produced by clang-18 on the machine that built this book (x86-64 Linux).
Compile and run
C.1 The function¶
It multiplies each element of x by 2.0 and stores it in y. Mountain Goat's x * 2 over a row of numbers lowers to exactly this kind of loop (Appendix B, Stage 2).
C.2 LLVM IR, unoptimised¶
clang-18 -S -emit-llvm -O0 (with -Xclang -disable-O0-optnone, which lets the later opt passes run on it) gives:
define dso_local void @scale(ptr noundef %0, ptr noundef %1, i64 noundef %2) #0 {
%4 = alloca ptr, align 8
%5 = alloca ptr, align 8
%6 = alloca i64, align 8
%7 = alloca i64, align 8
store ptr %0, ptr %4, align 8
store ptr %1, ptr %5, align 8
store i64 %2, ptr %6, align 8
store i64 0, ptr %7, align 8
br label %8
8: ; preds = %21, %3
%9 = load i64, ptr %7, align 8
%10 = load i64, ptr %6, align 8
%11 = icmp slt i64 %9, %10
br i1 %11, label %12, label %24
12: ; preds = %8
%13 = load ptr, ptr %5, align 8
%14 = load i64, ptr %7, align 8
%15 = getelementptr inbounds double, ptr %13, i64 %14
%16 = load double, ptr %15, align 8
%17 = fmul double %16, 2.000000e+00
%18 = load ptr, ptr %4, align 8
%19 = load i64, ptr %7, align 8
%20 = getelementptr inbounds double, ptr %18, i64 %19
store double %17, ptr %20, align 8
br label %21
21: ; preds = %12
%22 = load i64, ptr %7, align 8
%23 = add nsw i64 %22, 1
store i64 %23, ptr %7, align 8
br label %8, !llvm.loop !6
24: ; preds = %8
ret void
}
How to read it:
- A function is
define <return type> @name(<typed parameters>).ptris a pointer (LLVM 18 has one pointer type, without a pointee type),i64a 64-bit integer,doublea 64-bit float. - Values are SSA (as in Appendix B):
%4,%5, ... are defined once. Parameters are%0,%1,%2(unnamed values are numbered). - A basic block is a straight-line run of instructions ending in a terminator (
br,ret). A line like8:starts a block;; preds = %21, %3says which blocks can jump to it. allocareserves a stack slot;storeandloadwrite and read memory. At-O0clang gives every local variable (herey,x,n,i) a stack slot and loads it each time it is used.getelementptr(GEP) computes an address:getelementptr inbounds double, ptr %13, i64 %14is&x[i]. It does not read memory.icmp sltcompares signed integers (i < n);br i1 %c, label %a, label %bbranches on the result;br label %bjumps unconditionally.fmul doublemultiplies;add nsw i64adds integers (nsw: "no signed wrap", a promise that lets LLVM reason about the loop counter).
C.3 LLVM IR, optimised¶
The same function with -O1 (vectorising and unrolling switched off, so the loop is still one element at a time):
define dso_local void @scale(ptr nocapture noundef writeonly %0, ptr nocapture noundef readonly %1, i64 noundef %2) local_unnamed_addr #0 {
%4 = icmp sgt i64 %2, 0
br i1 %4, label %6, label %5
5: ; preds = %6, %3
ret void
6: ; preds = %3, %6
%7 = phi i64 [ %12, %6 ], [ 0, %3 ]
%8 = getelementptr inbounds double, ptr %1, i64 %7
%9 = load double, ptr %8, align 8, !tbaa !5
%10 = fmul double %9, 2.000000e+00
%11 = getelementptr inbounds double, ptr %0, i64 %7
store double %10, ptr %11, align 8, !tbaa !5
%12 = add nuw nsw i64 %7, 1
%13 = icmp eq i64 %12, %2
br i1 %13, label %5, label %6, !llvm.loop !9
}
The stack slots are gone: LLVM's mem2reg turned every alloca into an SSA value, and the loop counter is now a phi:
A phi is how SSA says "this value is 0 if we arrived from block %3 (the entry), and %12 (the incremented counter) if we arrived from block %6 (the loop body itself)". It is the only instruction whose result depends on which way control arrived, and every loop counter in optimised IR is one. The entry now tests n > 0 once (icmp sgt ... br) and the loop is a single block that tests i + 1 == n at its end, instead of testing before every iteration. The !tbaa and !llvm.loop marks are metadata that help later passes and can be ignored when reading.
C.4 x86-64 assembly¶
clang-18 -S -O1 turns that IR into machine instructions. In AT&T syntax (the default on Linux, source operand first):
scale: # @scale
# %bb.0:
testq %rdx, %rdx
jle .LBB0_3
# %bb.1:
xorl %eax, %eax
.LBB0_2: # =>This Inner Loop Header: Depth=1
movsd (%rsi,%rax,8), %xmm0 # xmm0 = mem[0],zero
addsd %xmm0, %xmm0
movsd %xmm0, (%rdi,%rax,8)
incq %rax
cmpq %rax, %rdx
jne .LBB0_2
.LBB0_3:
retq
and the same instructions in Intel syntax (destination first, -masm=intel):
scale: # @scale
# %bb.0:
test rdx, rdx
jle .LBB0_3
# %bb.1:
xor eax, eax
.LBB0_2: # =>This Inner Loop Header: Depth=1
movsd xmm0, qword ptr [rsi + 8*rax] # xmm0 = mem[0],zero
addsd xmm0, xmm0
movsd qword ptr [rdi + 8*rax], xmm0
inc rax
cmp rdx, rax
jne .LBB0_2
.LBB0_3:
ret
Registers. The System V AMD64 calling convention passes the first integer and pointer arguments in rdi, rsi, rdx, rcx, r8, r9, in that order: y is rdi, x is rsi, n is rdx. rax is a scratch register here, used as the loop counter i. xmm0 is one of the 16 vector/floating-point registers; movsd and addsd use only the low 64 bits of it (one double).
| instruction (AT&T) | what it does |
|---|---|
testq %rdx, %rdx / jle .LBB0_3 |
test n against itself; jump to the end if n <= 0 |
xorl %eax, %eax |
set rax to 0 (xor with itself is the cheapest way to make zero) |
movsd (%rsi,%rax,8), %xmm0 |
load the double at address rsi + 8*rax, i.e. x[i] |
addsd %xmm0, %xmm0 |
xmm0 = xmm0 + xmm0 |
movsd %xmm0, (%rdi,%rax,8) |
store it at y[i] |
incq %rax |
i++ |
cmpq %rax, %rdx / jne .LBB0_2 |
compare i with n; loop again if not equal |
retq |
return |
One thing the compiler did that is worth noticing: the source says x[i] * 2.0 and the assembly says addsd %xmm0, %xmm0. Doubling a number by adding it to itself gives exactly the same result as multiplying by 2 in floating point, and an add is not slower than a multiply, so LLVM chose the add. No flag allowed that; it is an exact identity. (Contrast Chapter 44's flags, which allow transformations that are not exact.)
C.5 The same stage, in this book's output¶
The LLVM IR that Appendix B's tiny.mg produces has the same ingredients, in a more verbose dress. Its first loop nest (x * 2 into a fresh buffer: an outer loop over the one row and an inner loop over the three columns), from 4_scale_add.ll:
37: ; preds = %55, %14
%38 = phi i64 [ %56, %55 ], [ 0, %14 ]
%39 = icmp slt i64 %38, 1
br i1 %39, label %40, label %57
40: ; preds = %37
br label %41
41: ; preds = %44, %40
%42 = phi i64 [ %54, %44 ], [ 0, %40 ]
%43 = icmp slt i64 %42, 3
br i1 %43, label %44, label %55
44: ; preds = %41
%45 = extractvalue { ptr, ptr, i64, [2 x i64], [2 x i64] } %21, 1
%46 = mul i64 %38, 3
%47 = add i64 %46, %42
%48 = getelementptr double, ptr %45, i64 %47
%49 = load double, ptr %48, align 8
%50 = fmul double %49, 2.000000e+00
%51 = mul i64 %38, 3
%52 = add i64 %51, %42
%53 = getelementptr double, ptr %29, i64 %52
store double %50, ptr %53, align 8
%54 = add i64 %42, 1
br label %41
55: ; preds = %41
%56 = add i64 %38, 1
br label %37
phi for each counter, icmp/br for each loop test, getelementptr/load/fmul/store for the element. The index arithmetic (mul, add) comes from flattening [row, column] into one offset (row * 3 + column), because memref indexing has been lowered away, and the extractvalue reads the buffer pointer out of the five-field memref descriptor from Appendix B. At -O0, which is what mgc uses by default, none of this is cleaned up; Chapter 37 shows what LLVM does to it at higher levels.
C.6 The tools¶
| command | does |
|---|---|
clang-18 -S -emit-llvm -O1 f.c |
C to LLVM IR text (f.ll) |
clang-18 -S -O1 f.c (-masm=intel) |
C to assembly (f.s) |
opt-18 -passes=... f.ll -S |
run LLVM optimisation passes on IR and print IR |
llc-18 f.ll |
LLVM IR to assembly |
llvm-mca-18 |
estimate how fast an assembly loop runs on a given CPU (Chapter 37 uses it) |
To see what any option does, compile a ten-line function both ways and read the two outputs, as this appendix does.