Skip to content

Lesson 9.8 — One function, seven IRs: LLVM IR, GIMPLE, SIL, MIR, Cranelift IR, WebAssembly and PIR

Techniques: LLVM IR; GCC's GIMPLE; Swift's SIL; Rust's MIR; Cranelift IR (CLIF); WebAssembly; Pebble's PIR · Pebble uses: PIR (front end → back end) and LLVM IR (back end) · Lab: — (theory, real dumps) · Prerequisites: Lessons 9.1–9.7, Ch 8 (the design space of IRs) · Time: 3 hours

The running example sum_pos has now been compiled by clang in every lesson. This lesson compiles the same function with six other compilers' intermediate representations and puts the results side by side, so that you can see which of LLVM IR's choices are universal (a CFG of blocks with one terminator each, SSA somewhere in the pipeline) and which are LLVM's own (phis instead of block arguments, poison, GEP, the object-file linkage model). Ch 8 surveys IR design in general; here the comparison is concrete, instruction by instruction.

1. Problem and motivation

Every compiler needs a representation between source and machine code, and the choices differ because the compilers' needs differ: GCC's GIMPLE grew out of its tree representation of C; Swift's SIL must express ownership and reference counting; Rust's MIR exists to run the borrow checker; Cranelift optimizes for compile speed in JITs and Wasm runtimes; WebAssembly is a portable, validated distribution format; PIR is this course's contract between front ends and the LLVM back end (docs/pir/pir-spec.md). Seeing the same function in each makes the trade-offs of Lessons 9.1–9.7 visible.

LLVM IR

The reference point: SSA values with phis, typed instructions, explicit memory through pointers and GEP, poison-based deferred UB, and the whole module in one self-contained unit [LA04].

GIMPLE

GCC lowers its GENERIC trees into GIMPLE, a three-address representation with at most one operation per statement, first in "high" form with nested scopes, then in a CFG, then in SSA form where each version of a variable is an SSA name s_15 [Mer03; GCC-GIMPLE].

Swift SIL

The Swift Intermediate Language sits between the type checker and LLVM IR. It keeps Swift's types, generics and ownership (@owned, @guaranteed), uses block arguments instead of phis, and is where Swift's mandatory optimizations and diagnostics (definite initialization, exclusivity) run [SWIFT-SIL].

Rust MIR

MIR is rustc's mid-level representation: a CFG of basic blocks over typed locals (_0 is the return place), with places and projections ((*_1)[_8]), explicit assert terminators for panicking checks, and no SSA. The borrow checker and const evaluation work on it; LLVM IR is generated from it [RUST-MIR].

Cranelift IR

Cranelift's CLIF is an SSA IR with block parameters, a small set of machine-oriented types (i8–i128, f32, f64, vectors), no pointer type, and integer operations that wrap or trap rather than produce poison [CL-Docs].

WebAssembly

Wasm is a stack machine with typed locals and structured control flow (block, loop, br, br_if): no arbitrary jumps, so a validator can check any module in one linear pass [HRS+17; WASM-SPEC].

PIR

PIR (Pebble IR) is modeled on MIR and SIL: typed locals instead of SSA registers, places with projections, explicit assert for safety checks, and a text format any front end can print (docs/pir/pir-spec.md; Ch 11 lowers it to LLVM IR).

2. Definitions and algorithms

Definition 9.8.1 (Design coordinates of an IR)

We compare IRs along six coordinates: values (SSA registers / typed mutable locals / an operand stack); merges (phi instructions / block arguments / assignments to locals / stack at block ends); control (arbitrary CFG / structured nesting); memory (raw pointers with arithmetic / typed places with projections / one linear byte array); undefined behavior (poison + immediate UB / traps / undefined behavior inherited from the source language / none); unit (module / translation unit / crate / function).

Definition 9.8.2 (Block-argument SSA)

In block-argument SSA, each block \(b\) has formal parameters \(\langle a^b_1, \dots, a^b_k \rangle\), and every branch to \(b\) passes actual arguments: br b(v1, …, vk), brif c, b(…), b'(…). Parameters are defined at the start of \(b\); dominance is as in Definition 9.3.3, with the arguments of an edge \(p \to b\) used at the end of \(p\). The entry block's parameters are the function's parameters.

LLVM IR

LLVM IR in these coordinates: SSA registers; phis; arbitrary CFG; raw pointers with GEP; poison + UB; module.

Algorithm 9.8.3 (Phis to block arguments)

  • Input: a well-formed LLVM function (Definition 9.3.4).
  • Output: an equivalent function in block-argument SSA.
  • Precondition: none.
  • Postcondition: every block \(b\) has one parameter per phi of \(b\), in order; every edge \(p \to b\) passes the phis' incoming values for \(p\).
  • Invariant: after processing block \(b\), all uses of \(b\)'s phis refer to \(b\)'s parameters, and every predecessor terminator of \(b\) passes exactly \(\lvert\mathrm{phis}(b)\rvert\) arguments to \(b\).
function PhisToBlockArgs(f):
    for b in blocks(f):
        params ← []
        for φ in phis(b):                         # W3: all phis are at the top
            a ← new parameter of b with type(φ)
            replace all uses of φ by a; params.append((φ, a))
        for p in preds(b) (each edge once, with multiplicity):
            args ← [incoming value of φ for p for (φ, a) in params]    # W4 gives exactly one per edge
            make the terminator of p pass args on this edge to b
        delete the phis of b

Algorithm 9.8.4 (Block arguments to phis)

  • Input: a function in block-argument SSA.
  • Output: an equivalent well-formed LLVM function.
  • Precondition: none (edges may pass different arguments to the same successor).
  • Postcondition: W1–W6 hold; in particular W4 (one entry per edge, equal entries for the same predecessor).
  • Invariant: every edge whose arguments were turned into phi entries comes from a block that branches to \(b\) only once or passes identical arguments on all its edges to \(b\).
function BlockArgsToPhis(f):
    for each block p and successor b such that p has two edges to b with different arguments:
        split one of the edges: insert a new block e with a single edge e → b
            carrying p's arguments for that edge, and branch p → e instead
    for b in blocks(f):
        for j, a in enumerate(params(b)):
            φ ← phi with one entry (arg_j(edge), source block of edge) per edge into b
            replace all uses of a by φ
        remove the arguments from every branch to b

GIMPLE

GIMPLE (SSA form): SSA names (versions of declared variables and temporaries); PHI nodes; arbitrary CFG with EH edges; memory through MEM_REF and typed references to declarations; UB inherited from C types (TYPE_OVERFLOW_UNDEFINED), no poison; the translation unit (or the whole program under LTO).

Swift SIL

SIL: SSA values; block arguments (Definition 9.8.2); arbitrary CFG; addresses of typed memory, plus ownership-tracked object values; UB only in unsafe operations, traps elsewhere; module.

Rust MIR

MIR: typed locals _0 … _n, mutable (not SSA); merges by assignment to locals; arbitrary CFG with explicit unwind edges; places with projections; UB only through unsafe (Miri defines it), checks as assert terminators; one body per function (grouped by crate).

Cranelift IR

CLIF: SSA values v0, v1, …; block parameters; arbitrary CFG; integer addresses (load.i64 v14), no pointer type; wrap-or-trap integers, no poison; function.

WebAssembly

Wasm: an operand stack plus typed locals; merges through locals and the stack at end; structured control (block/loop/if, br n to the \(n\)-th enclosing label); one linear memory addressed by i32/i64 offsets; traps (no UB: an out-of-bounds access traps deterministically); module.

Definition 9.8.5 (Structured control flow)

A Wasm function body is a sequence of instructions in which block … end, loop … end and if … else … end nest properly; br n jumps to the \(n\)-th enclosing label: to the end of a block (forward) or to the start of a loop (backward). The induced CFG therefore has only forward edges to block ends and backward edges to loop headers that enclose the branch.

PIR

PIR: typed locals, not SSA; merges by assignment; arbitrary CFG; places with projections ((*_0)[_3]), references &T; explicit assert terminators for bounds and overflow checks, UB only for violations the verifier cannot catch (docs/pir/pir-spec.md §12); module.

3. Worked example

The same function in seven IRs (all real tool output, Section 7). The correspondence table follows each C construct through them:

C construct LLVM IR (-O2) GIMPLE (-O2) MIR (-O) CLIF (via Wasm) Wasm PIR
s at the loop head %s.09 = phi … s_15 = PHI <s_5(6), 0(3)> local _0 (the return place) block param v21 of block5 local 2 local _2
i %i.010 = phi … pointer IV ivtmp.9_7 local _2 pointer v11, counter v25 locals 0 (pointer) and 1 (count) local _3
a[i] GEP [8 x i8] + load MEM[(const long long int *)_18] place (*_1)[_8] iadd v13, v12 + load.i64 i64.load 0 place (*_0)[_3]
bounds check none (C) none assert(Lt(_8, _5), "index out of bounds…") none (Wasm traps) none (the memory access traps) assert _5, bounds
if (a[i] > 0) s += a[i] smax + add nuw nsw branch + PHI switchInt + Add smax + iadd i64.select + i64.add br + saddo/assert + add
loop control icmp eq %inc, %n ivtmp.9_6 != _22 Lt(_4, _5) count down v65, brif br_if 0 to loop slt _3, _1

Phis to block arguments (Algorithm 9.8.3) on the -O2 LLVM IR of sum_pos:

step block phis action result
1 entry — nothing parameters %a, %n
2 for.cond.cleanup %s.0.lcssa new param c0; edges entry → cleanup(0), for.body → cleanup(%spec.select) for.cond.cleanup(c0)
3 for.body %i.010, %s.09 params b0, b1; entry → for.body(0, 0), for.body → for.body(%inc, %spec.select) for.body(b0, b1)
4 done br i1 %exitcond.not, for.cond.cleanup(%spec.select), for.body(%inc, %spec.select)

That is, up to naming, Cranelift's block5(v11, v21, v25) with the arguments (v61, v22, v65) on the back edge: Cranelift carries the pointer, the sum and the down-counting length as block parameters.

Why Wasm needs no block arguments. The Wasm version keeps s in local 2 and assigns it in the loop; values flow through locals and the operand stack. The CFG is structured (Definition 9.8.5): one loop (the for.body cycle) inside two blocks (the early exits for n ≤ 0). A br_if 0 inside the loop jumps back to its start; br 1 jumps forward to the end of the outer block.

SIL, by hand. No Swift toolchain was available to produce SIL for sum_pos (Section 7 quotes the SIL documentation instead). Following the conventions of [SWIFT-SIL] (block arguments, cond_br, builtin integer operations), the loop of an equivalent Swift function would have a header block taking the sum and the index as arguments, e.g. bb1(%s : $Builtin.Int64, %i : $Builtin.Int64), with br bb1(%s2, %i2) on the back edge; this sketch is illustrative, not compiler output.

Try it

Take the lab's @collatz_steps (E1) and rewrite it by hand in block-argument form (Algorithm 9.8.3), then translate it back (Algorithm 9.8.4): you should get your original phis back.

4. Invariants and correctness

LLVM IR

Theorem 9.8.6 (Phis and block arguments are equivalent)

Algorithm 9.8.3 maps a well-formed LLVM function to a block-argument function with the same executions (same values at every program point), and Algorithm 9.8.4 maps any block-argument function to a well-formed LLVM function with the same executions, adding only empty edge blocks.

Proof

Algorithm 9.8.3. By (W3) and (W4), each phi of \(b\) has exactly one incoming value per incoming edge. By Definition 9.3.8, on edge \(p \to b\) all phis of \(b\) are assigned simultaneously the values their entries for \(p\) have at the end of \(p\). Block-argument semantics (Definition 9.8.2) binds the parameters of \(b\) simultaneously to the arguments evaluated at the end of \(p\); the algorithm passes exactly the phi entries, so every parameter gets the value the phi would have had. Dominance: a phi entry's value dominates the end of \(p\) (Definition 9.3.3), which is where the argument is used. So well-formedness and values are preserved. Algorithm 9.8.4. After edge splitting, every predecessor \(p\) of \(b\) either has one edge to \(b\) or passes identical arguments on all its edges, so building one phi entry per edge satisfies (W4), including the "equal entries for the same predecessor" clause, which was the only obstacle (Lesson 9.3's box "Phi entries are per edge"). A split block \(e\) executes no instruction but the branch, so executions differ only by passing through \(e\). Values are equal by the same simultaneous-assignment argument as above.

GIMPLE

Proposition 9.8.7 (GIMPLE's SSA names satisfy the same dominance property)

In GIMPLE SSA form verified by verify_ssa, every SSA name's definition dominates each of its uses (for a PHI argument, the end of the corresponding predecessor), so Theorem 9.3.5 holds for GIMPLE as well.

Proof sketch (full check: verify_ssa in gcc/tree-ssa.cc, GCC 15 [GCC-SSA])

verify_ssa walks the dominator tree, checks that each SSA name has exactly one definition, and verifies for each use (with PHI arguments attributed to the incoming edge's source block) that the definition's block dominates the use's block; this is Definition 9.3.3 for GIMPLE. The proof of Theorem 9.3.5 then applies verbatim, since it only uses dominance and the single-definition property.

Swift SIL

Proposition 9.8.8 (SIL's block arguments are LLVM phis)

SIL functions without ownership constraints translate to LLVM IR by Algorithm 9.8.4 with the same executions.

Proof

SIL block arguments bound by br/cond_br are exactly Definition 9.8.2 ("This corresponds to LLVM's phi nodes" [SWIFT-SIL]); Theorem 9.8.6 applies. Terminators that produce block arguments (e.g. switch_enum forwarding a payload) are first expanded into a branch plus an extraction instruction in the successor, after which the same argument holds. Ownership annotations have no run-time effect after lowering (they are erased once reference-counting operations are explicit).

Rust MIR

Proposition 9.8.9 (MIR and PIR locals become SSA values)

A MIR or PIR local whose address is never taken lowers to an alloca that satisfies Proposition 9.4.12, so mem2reg turns it into SSA values; in particular every local of sum_pos in the MIR and PIR versions becomes a register value.

Proof

Lowering creates one alloca per local and accesses it only with load/store of the local's type (Ch 11; the PIR box shows this -O0 lowering). A local whose address is never taken (no &_k rvalue, not a by-reference argument) has no other users, which is exactly the hypothesis of Proposition 9.4.12. In sum_pos, no local's address is taken except _0 of @main in the PIR version, which is not promoted until inlining.

Cranelift IR

Proposition 9.8.10 (No poison in CLIF: every integer operation is total or traps)

Every CLIF integer instruction either returns a value (wrapping modulo \(2^N\)) or traps; hence Cranelift needs no freeze, and speculation of a non-trapping operation is always a refinement.

Proof

CLIF's arithmetic (iadd, imul, shifts with the amount taken modulo the width) is defined on all inputs by wrapping; the partial operations (sdiv, udiv by zero, sdiv overflow, heap accesses out of bounds) trap deterministically [CL-Docs]. With no poison-producing operations, Definition 9.7.4's non-trivial rules never fire; the only source of "undefinedness" is a trap, which is an observable behavior, not UB. Speculating an operation that cannot trap computes a value that is simply unused on the other path, as in Proposition 9.7.12 without the poison case.

WebAssembly

Proposition 9.8.11 (Structured control flow and reducibility)

The CFG of a Wasm function is reducible; conversely, every reducible CFG can be expressed with Wasm's block/loop/br without duplicating code, while an irreducible CFG needs code duplication or an explicit dispatch variable.

Proof sketch (full proof and algorithm: [Ram22]; reducibility: Ch 15)

Wasm ⇒ reducible: by Definition 9.8.5 every backward edge targets the start of an enclosing loop, and every path into a loop's body passes through its start (a loop is entered only by falling into it or by branching to its label from inside), so each backward edge's target dominates its source: every retreating edge is a back edge, the characterization of reducibility. Reducible ⇒ Wasm: Ramsey gives a recursive translation driven by the dominator tree, placing a loop at each loop header and a block for each forward merge, and proves it duplicates no code [Ram22]; LLVM's WebAssembly back end implements a variant (WebAssemblyCFGStackify.cpp). Irreducible CFGs have a cycle with two entries, which no single loop can represent; LLVM first makes them reducible (WebAssemblyFixIrreducibleControlFlow.cpp, a dispatch block with a label variable).

PIR

Proposition 9.8.12 (Explicit checks make PIR's lowering UB-free)

If a PIR function passes the verifier, every arithmetic overflow and every out-of-bounds index that the source language defines as a trap is guarded by an assert that dominates the operation, so the LLVM IR produced by the lowering has no path on which such an operation executes with an invalid operand.

Proof sketch (rules: docs/pir/pir-spec.md §10–§13; lowering: Ch 11)

Front ends emit checked operations as a predicate, an assert and the wrapping operation (saddo + assert !_7, overflow + add in the example); the assert is a terminator whose failure edge goes to a trap block (the lowered %trap.overflow in the box). The wrapping add is lowered without nsw, so even on a hypothetical unchecked path it produces a wrapped value, not poison; the assert dominates it within the block structure, so on every path that reaches it the check succeeded. Out-of-bounds accesses are guarded the same way, and only then is the GEP emitted with inbounds.

5. Complexity

IR Construction from the level above Validation / verification Space per operation Variables
LLVM IR \(O(n)\) plus SSA construction (\(O(n \cdot \alpha)\) typical, Ch 16) \(O(i + u + T_{\mathrm{dom}})\) (Proposition 9.3.12) one object per instruction and per use \(n\) = instructions
GIMPLE gimplification \(O(n)\), into-SSA like LLVM verify_ssa like LLVM trees, larger per statement —
Swift SIL SILGen \(O(n)\); ownership verification SILVerifier, linear per function one instruction per operation —
Rust MIR MIR building \(O(n)\); borrow checking can be superlinear MIR validation, linear statements over places —
Cranelift IR from Wasm: one pass, \(O(n)\) CLIF verifier, linear compact (u32 entity indices) —
WebAssembly — validation in one linear pass (Proposition 9.8.13) 1–10 bytes per instruction —
PIR lowering \(O(n)\) (Ch 11) PIR verifier, linear text + C++ objects —

Proposition 9.8.13 (Wasm validates in linear time)

A Wasm function body of \(n\) instructions with maximum nesting depth \(d\) is validated in \(O(n)\) time and \(O(n + d)\) space.

Proof sketch (full algorithm: [WASM-SPEC], Appendix: Validation Algorithm)

The validation algorithm keeps a stack of operand types and a stack of control frames (one per enclosing block/loop/if). Each instruction pops and pushes a constant number of types (its type signature), except br/end, which compare the operand stack against the target frame's label types, a cost bounded by the label's arity; every push is popped at most once, so the total work is linear in \(n\) plus the types pushed, which is \(O(n)\). The stacks hold at most \(n\) operand types and \(d\) frames.

Pathological input. For LLVM-style SSA IRs the pathological case is SSA construction on huge functions with many variables live across many joins (phi explosion, Ch 16). For Wasm it is the translation from an irreducible CFG: node splitting can grow the code exponentially, which is why LLVM uses a dispatch variable instead (Proposition 9.8.11). For MIR, borrow checking of functions with thousands of locals and complex lifetimes has shown superlinear compile times in rustc.

6. Variants and refinements

LLVM IR

  • MLIR [LAB+21]: generalizes LLVM IR's design into extensible dialects with regions and block arguments; the llvm dialect is LLVM IR inside MLIR.
  • Machine IR (MIR, LLVM's): the post-instruction-selection representation (Ch 21), also printable as text (llc -stop-after); not to be confused with Rust's MIR.

GIMPLE

  • High GIMPLE vs low GIMPLE vs SSA: GCC lowers in stages (-fdump-tree-gimple, -fdump-tree-lower, -fdump-tree-ssa), each with fewer constructs; RTL follows after GIMPLE.
  • GIMPLE front end (-fgimple): GCC can parse GIMPLE directly for unit tests, like .ll files for LLVM.

Swift SIL

  • Raw vs canonical SIL: SILGen produces raw SIL; mandatory passes (definite initialization, diagnostics) produce canonical SIL, which the optimizer transforms.
  • OSSA (ownership SSA): SIL with ownership rules checked by the verifier, lowered before IRGen.

Rust MIR

  • Built / analysis / runtime MIR phases: MIR changes shape across phases (drop elaboration, borrowck-only constructs removed); -Z dump-mir shows each.
  • Miri: an interpreter for MIR that detects UB in unsafe code, the Rust analogue of llubi.

Cranelift IR

  • ISLE: Cranelift's instruction selection rules are written in a DSL over CLIF patterns (Ch 21).
  • E-graph mid-end: Cranelift's optimizer rewrites CLIF in an e-graph (aegraph) instead of a sequence of passes (Ch 17).

WebAssembly

  • Tail calls, exceptions, GC proposals: new instructions extend the structured model (return_call, try_table, typed references).
  • Stackification in producers: LLVM's back end turns SSA values into stack operands where possible and locals elsewhere (WebAssemblyRegStackify.cpp).

PIR

  • Checked vs unchecked operations: PIR spells overflow checks as saddo + assert, which Ch 18 removes when ranges prove them redundant.
  • External front ends: any language can target PIR by printing text; the Rust front end of Ch 24 is judged by the same conformance tests.

7. In real compilers

LLVM IR

LLVM

llvm/include/llvm/IR/Instructions.h and the LangRef describe it [LLVM-LangRef]; clang produces it in clang/lib/CodeGen/ (LLVM 23.1.2).

Find where LLVM does it. In llvm/lib/Target/WebAssembly/, find the pass that orders blocks and inserts block/loop markers. Question: what is its source file called?

sum_pos in LLVM IR (clang -O2)

Reproduce (clang 23.1.2):

cat > sum_pos.c <<'EOF'
long long sum_pos(const long long *a, long long n) {
  long long s = 0;
  for (long long i = 0; i < n; i++)
    if (a[i] > 0)
      s += a[i];
  return s;
}
EOF
clang-23 --target=x86_64-unknown-linux-gnu -O2 -fno-vectorize -fno-unroll-loops -fno-discard-value-names \
  -S -emit-llvm sum_pos.c -o - | sed -n '/^define/,/^}/p'

Output (complete):

define dso_local range(i64 0, -9223372036854775808) i64 @sum_pos(ptr nofree noundef readonly captures(none) %a, i64 noundef %n) local_unnamed_addr #0 {
entry:
  %cmp8 = icmp sgt i64 %n, 0
  br i1 %cmp8, label %for.body, label %for.cond.cleanup

for.cond.cleanup:                                 ; preds = %for.body, %entry
  %s.0.lcssa = phi i64 [ 0, %entry ], [ %spec.select, %for.body ]
  ret i64 %s.0.lcssa

for.body:                                         ; preds = %entry, %for.body
  %i.010 = phi i64 [ %inc, %for.body ], [ 0, %entry ]
  %s.09 = phi i64 [ %spec.select, %for.body ], [ 0, %entry ]
  %arrayidx = getelementptr inbounds nuw [8 x i8], ptr %a, i64 %i.010
  %0 = load i64, ptr %arrayidx, align 8, !tbaa !9
  %add = tail call i64 @llvm.smax.i64(i64 %0, i64 0)
  %spec.select = add nuw nsw i64 %add, %s.09
  %inc = add nuw nsw i64 %i.010, 1
  %exitcond.not = icmp eq i64 %inc, %n
  br i1 %exitcond.not, label %for.cond.cleanup, label %for.body, !llvm.loop !11
}

What to notice: the reference point of Section 3's table: two phis in the loop body, the if turned into smax (Lesson 9.3), the address in the canonical [8 x i8] GEP form (Lesson 9.4), and flags everywhere (Lesson 9.7). The loop was rotated, so the test is at the bottom.

GIMPLE

GCC

gcc/gimple.def (the statement codes), gcc/gimplify.cc (GENERIC → GIMPLE), gcc/tree-into-ssa.cc (SSA construction), gcc/tree-ssa.cc (verify_ssa), GCC 15 [GCC-GIMPLE, GCC-SSA].

  • Clang/LLVM equivalent: the -O0 IR plus mem2reg; GIMPLE's SSA names correspond to LLVM values, its PHI to phi.

Find where GCC does it. In gcc/tree-ssa.cc, find verify_ssa. Question: which tree does it walk to check that definitions dominate uses?

sum_pos in GIMPLE SSA (gcc -O2)

Reproduce (gcc 14.2.0, Ubuntu 24.04, the GCC available in the authoring environment; not re-run with GCC 15, whose sources the info boxes cite):

cat > sum_pos.c <<'EOF'
long long sum_pos(const long long *a, long long n) {
  long long s = 0;
  for (long long i = 0; i < n; i++)
    if (a[i] > 0)
      s += a[i];
  return s;
}
EOF
gcc-14 -O2 -fno-tree-vectorize -fdump-tree-optimized=stdout -S -o /dev/null sum_pos.c \
  | sed -n '/^long long int sum_pos/,/^}/p'

Output (complete):

long long int sum_pos (const long long int * a, long long int n)
{
  unsigned long ivtmp.9;
  long long int s;
  long long int _4;
  void * _18;
  unsigned long _19;
  unsigned long _20;
  unsigned long _22;

  <bb 2> [local count: 118111598]:
  if (n_8(D) > 0)
    goto <bb 3>; [89.00%]
  else
    goto <bb 7>; [11.00%]

  <bb 3> [local count: 105119322]:
  ivtmp.9_14 = (unsigned long) a_10(D);
  _19 = (unsigned long) n_8(D);
  _20 = _19 * 8;
  _22 = ivtmp.9_14 + _20;

  <bb 4> [local count: 955630224]:
  # s_15 = PHI <s_5(6), 0(3)>
  # ivtmp.9_7 = PHI <ivtmp.9_6(6), ivtmp.9_14(3)>
  _18 = (void *) ivtmp.9_7;
  _4 = MEM[(const long long int *)_18];
  if (_4 > 0)
    goto <bb 5>; [59.00%]
  else
    goto <bb 6>; [41.00%]

  <bb 5> [local count: 563821836]:
  s_11 = _4 + s_15;

  <bb 6> [local count: 955630226]:
  # s_5 = PHI <s_15(4), s_11(5)>
  ivtmp.9_6 = ivtmp.9_7 + 8;
  if (ivtmp.9_6 != _22)
    goto <bb 4>; [89.00%]
  else
    goto <bb 7>; [11.00%]

  <bb 7> [local count: 118111600]:
  # s_16 = PHI <s_5(6), 0(2)>
  return s_16;

}

What to notice: SSA names are versions of variables (s_15, s_5, s_11, s_16); # s_15 = PHI <s_5(6), 0(3)> lists (value, predecessor block) pairs like an LLVM phi. Unlike clang, GCC kept the if as a branch and a PHI (no smax), and IVOPTS replaced the index by a pointer induction variable (ivtmp.9) compared against an end pointer. Block frequencies ([local count: …]) and branch probabilities are printed inline.

Swift SIL

Swift

docs/SIL/SIL.md (the specification), lib/SILGen/ (AST → SIL), lib/SIL/Verifier/SILVerifier.cpp, lib/IRGen/ (SIL → LLVM IR), Swift 6.2 [SWIFT-SIL].

  • SIL vs LLVM IR: block arguments instead of phis (Proposition 9.8.8); Swift types ($Int, generics) survive until IRGen.

Find where Swift does it. In docs/SIL/SIL.md (tag swift-6.2-RELEASE), find the section on basic block arguments. Question: which two terminators may pass values to a block with multiple predecessors?

SIL's block arguments, quoted from the SIL specification

Reproduce (quotes the pinned Swift 6.2 documentation; no Swift toolchain was available in the authoring environment, so this is not swiftc -emit-sil output):

curl -s https://raw.githubusercontent.com/swiftlang/swift/swift-6.2-RELEASE/docs/SIL/SIL.md \
  | sed -n '/^- Phi arguments/,/return %phi/p'

Output (complete; the text of the specification):

- Phi arguments: If a block has multiple predecessor blocks, the terminators of
  the predecessor blocks must be `br` or `cond_br` instructions and the
  operand values of those branch instructions are passed to the block's
  arguments. This corresponds to LLVM's phi nodes. Basic block arguments are
  bound by the branch from the predecessor block:

```
      cond_br %cond, bb1, bb2
    bb1:
      br bb3(%1)
    bb2:
      br bb3(%2)
    bb3(%phi : $Builtin.Int):
      return %phi

What to notice: "This corresponds to LLVM's phi nodes": the branch br bb3(%1) passes the value, and bb3(%phi : $Builtin.Int) receives it, exactly Definition 9.8.2 and Theorem 9.8.6. To see SIL for sum_pos itself, run swiftc -O -emit-sil on a Swift version of it with a Swift 6.x toolchain.

Rust MIR

rustc

compiler/rustc_middle/src/mir/syntax.rs (StatementKind, TerminatorKind, Rvalue, Place), compiler/rustc_mir_build/ (THIR → MIR), compiler/rustc_codegen_ssa/src/mir/ (MIR → LLVM IR via the backend-independent codegen), rustc 1.94 [RUST-MIR].

  • MIR vs PIR: PIR copies MIR's locals, places and assert terminators; PIR drops lifetimes and StorageLive/StorageDead, and adds a stable text format.

Find where rustc does it. In compiler/rustc_middle/src/mir/syntax.rs, find TerminatorKind::Assert. Question: what does the unwind field of the assert in the box below say happens on failure?

sum_pos in Rust MIR (rustc --emit=mir)

Reproduce (rustc 1.94.1):

cat > sum_pos.rs <<'EOF'
pub fn sum_pos(a: &[i64]) -> i64 {
    let mut s = 0;
    let mut i = 0;
    while i < a.len() {
        if a[i] > 0 {
            s += a[i];
        }
        i += 1;
    }
    s
}
EOF
rustc --crate-type=lib -C opt-level=2 --emit=mir -o - sum_pos.rs | sed -n '/^fn sum_pos/,/^}/p' \
  | grep -v "StorageLive\|StorageDead"

Output (complete, without the StorageLive/StorageDead statements):

fn sum_pos(_1: &[i64]) -> i64 {
    debug a => _1;
    let mut _0: i64;
    let mut _3: bool;
    let mut _4: usize;
    let mut _5: usize;
    let mut _6: bool;
    let mut _7: i64;
    let _8: usize;
    let mut _9: bool;
    let mut _10: i64;
    let _11: usize;
    let mut _12: bool;
    scope 1 {
        debug s => _0;
        let mut _2: usize;
        scope 2 {
            debug i => _2;
        }
    }

    bb0: {
        _0 = const 0_i64;
        _2 = const 0_usize;
        goto -> bb1;
    }

    bb1: {
        _4 = copy _2;
        _5 = PtrMetadata(copy _1);
        _3 = Lt(move _4, copy _5);
        switchInt(move _3) -> [0: bb8, otherwise: bb2];
    }

    bb2: {
        _8 = copy _2;
        _9 = Lt(copy _8, copy _5);
        assert(move _9, "index out of bounds: the length is {} but the index is {}", copy _5, copy _8) -> [success: bb3, unwind continue];
    }

    bb3: {
        _7 = copy (*_1)[_8];
        _6 = Gt(move _7, const 0_i64);
        switchInt(move _6) -> [0: bb6, otherwise: bb4];
    }

    bb4: {
        _11 = copy _2;
        _12 = Lt(copy _11, copy _5);
        assert(move _12, "index out of bounds: the length is {} but the index is {}", copy _5, copy _11) -> [success: bb5, unwind continue];
    }

    bb5: {
        _10 = copy (*_1)[_11];
        _0 = Add(copy _0, move _10);
        goto -> bb7;
    }

    bb6: {
        goto -> bb7;
    }

    bb7: {
        _2 = Add(copy _2, const 1_usize);
        goto -> bb1;
    }

    bb8: {
        return;
    }
}

What to notice: no SSA: s lives in _0 (the return place) and is updated by _0 = Add(copy _0, move _10); i in _2. Each a[i] is a place (*_1)[_8] guarded by an explicit bounds-check assert terminator (twice, since a[i] appears twice). In release mode the += has no overflow check; in debug mode MIR would have AddWithOverflow and another assert.

Cranelift IR

Cranelift

cranelift/codegen/src/ir/instructions.rs and cranelift/codegen/src/ir/dfg.rs (instructions, values, block parameters), cranelift/docs/ir.md (the IR reference), cranelift/codegen/src/verifier/mod.rs, Wasmtime 37.0.2 [CL-Docs].

  • CLIF vs LLVM IR: block parameters, no pointer type (iadd on i64 addresses), and no poison (Proposition 9.8.10).

Find where Cranelift does it. In cranelift/codegen/src/ir/dfg.rs, find how block parameters are appended. Question: what is the method's name?

sum_pos in Cranelift IR (via Wasm, wasmtime --emit-clif)

Reproduce (clang 23.1.2, wasmtime 37.0.2 from its GitHub release):

cat > sum_pos.c <<'EOF'
long long sum_pos(const long long *a, long long n) {
  long long s = 0;
  for (long long i = 0; i < n; i++)
    if (a[i] > 0)
      s += a[i];
  return s;
}
EOF
clang-23 --target=wasm32 -O2 -fno-vectorize -fno-unroll-loops -nostdlib -Wl,--no-entry -Wl,--export=sum_pos \
  sum_pos.c -o sum_pos.wasm
mkdir -p clif && wasmtime compile --emit-clif clif sum_pos.wasm -o sum_pos.cwasm
cat "clif/wasm[0]--function[0]--sum_pos.clif"

Output (complete):

;; Intermediate Representation of function <wasm[0]::function[0]::sum_pos>:
function u0:0(i64 vmctx, i64, i32, i64) -> i64 tail {
    gv0 = vmctx
    gv1 = load.i64 notrap aligned readonly gv0+8
    gv2 = load.i64 notrap aligned gv1+16
    gv3 = vmctx
    gv4 = load.i64 notrap aligned gv3+64
    gv5 = load.i64 notrap aligned readonly can_move checked gv3+56
    stack_limit = gv2

                                block0(v0: i64, v1: i64, v2: i32, v3: i64):
@003f                               v5 = iconst.i64 0
                                    v36 = icmp sgt v3, v5  ; v5 = 0
@0049                               v8 = uextend.i32 v36
@004a                               brif v8, block3, block4

                                block4:
                                    v56 = iconst.i64 0
@0050                               jump block2(v56)  ; v56 = 0

                                block3:
                                    v57 = iconst.i64 0
@005b                               v13 = load.i64 notrap aligned readonly can_move checked v0+56
@0047                               v6 = iconst.i64 1
@006f                               v23 = iconst.i32 8
@0076                               v26 = iconst.i64 -1
@0057                               jump block5(v2, v57, v3)  ; v57 = 0

                                block5(v11: i32, v21: i64, v25: i64):
@005b                               v12 = uextend.i64 v11
@005b                               v14 = iadd.i64 v13, v12
@005b                               v15 = load.i64 little heap v14
                                    v58 = iconst.i64 1
                                    v59 = icmp ne v25, v58  ; v58 = 1
@007c                               v31 = uextend.i32 v59
                                    v60 = iconst.i32 8
                                    v61 = iadd v11, v60  ; v60 = 8
                                    v62 = iconst.i64 0
                                    v63 = smax v15, v62  ; v62 = 0
@006a                               v22 = iadd v63, v21
                                    v64 = iconst.i64 -1
                                    v65 = iadd v25, v64  ; v64 = -1
@007d                               brif v31, block5(v61, v22, v65), block7

                                block7:
@007f                               jump block6

                                block6:
@0080                               jump block2(v22)

                                block2(v32: i64):
@0083                               jump block1(v32)

                                block1(v4: i64):
@0083                               return v4
}

What to notice: block5(v11: i32, v21: i64, v25: i64) is the loop header with three block parameters (pointer, sum, remaining count), passed by brif v31, block5(v61, v22, v65), block7 on the back edge: Algorithm 9.8.3's output shape. Addresses are integers: iadd.i64 v13, v12 adds the linear memory's base (gv5) to the Wasm address, and the load is marked heap. smax came from the Wasm i64.select pattern. The @00xx prefixes are byte offsets in the Wasm module.

WebAssembly

LLVM

llvm/lib/Target/WebAssembly/WebAssemblyCFGStackify.cpp (inserts block/loop/end markers), WebAssemblyFixIrreducibleControlFlow.cpp (Proposition 9.8.11), WebAssemblyRegStackify.cpp (values on the operand stack) (LLVM 23.1.2).

  • Validators in Wasm engines (V8's src/wasm/function-body-decoder-impl.h, Wasmtime's wasmparser crate) implement the linear validation of Proposition 9.8.13.

Find where LLVM does it. In llvm/lib/Target/WebAssembly/WebAssemblyCFGStackify.cpp, find where a LOOP marker is placed. Question: at which block of a loop is it placed?

sum_pos in WebAssembly (clang --target=wasm32)

Reproduce (clang 23.1.2):

cat > sum_pos.c <<'EOF'
long long sum_pos(const long long *a, long long n) {
  long long s = 0;
  for (long long i = 0; i < n; i++)
    if (a[i] > 0)
      s += a[i];
  return s;
}
EOF
clang-23 --target=wasm32 -O2 -fno-vectorize -fno-unroll-loops -S sum_pos.c -o - | sed -n '/^sum_pos:/,/end_function/p'

Output (complete; LLVM's Wasm assembly syntax, one instruction per line):

sum_pos:                                # @sum_pos
    .functype   sum_pos (i32, i64) -> (i64)
    .local      i64, i64
# %bb.0:
    block       
    block       
    local.get   1
    i64.const   1
    i64.ge_s
    br_if       0                               # 0: down to label1
# %bb.1:
    i64.const   0
    local.set   2
    br          1                               # 1: down to label0
.LBB0_2:
    end_block                               # label1:
    i64.const   0
    local.set   2
.LBB0_3:                                # =>This Inner Loop Header: Depth=1
    loop                                        # label2:
    local.get   0
    i64.load    0
    local.tee   3
    i64.const   0
    local.get   3
    i64.const   0
    i64.gt_s
    i64.select
    local.get   2
    i64.add 
    local.set   2
    local.get   0
    i32.const   8
    i32.add 
    local.set   0
    local.get   1
    i64.const   -1
    i64.add 
    local.tee   1
    i64.eqz
    i32.eqz
    br_if       0                               # 0: up to label2
.LBB0_4:
    end_loop
    end_block                               # label0:
    local.get   2
                                        # fallthrough-return
    end_function

What to notice: structured control (Definition 9.8.5): block, block, loop nest; br_if 0 inside the loop jumps back to the loop label, br_if 0 / br 1 before it jump forward to block ends. There are no phis: s is local 2, set before the loop and updated with local.set 2. The pointer is i32 (wasm32), the values i64. local.tee keeps a value on the stack and in a local at once.

PIR

Pebble

docs/pir/pir-spec.md (the specification: grammar, verifier rules V1–…, lowering table), pebble/include/pebble/PIR/ (C++ API: Parser.h, Verifier.h, Interpreter.h), pebble/tools/pir-run, pebble/tools/pir-opt; lowering to LLVM IR in pebble/include/pebble/CodeGen/PIRToLLVM.h (Ch 11).

  • PIR vs MIR: the same locals/places/assert design; PIR's text format is versioned (pir 1.0) and is the contract for external front ends.

Find where Pebble does it. In docs/pir/pir-spec.md §17, find how a PIR local is lowered. Question: which LLVM instruction does every local become at first?

sum_pos in PIR: verified, run, and lowered to LLVM IR

Reproduce (course tools pir-opt, pir-run, pebblec from a build of this repository with LLVM 23.1.2; run from the repository root with B=build/<preset>/bin):

B=${B:-build/ch09-sol/bin}
cat > "${TMPDIR:-/tmp}/sum_pos.pir" <<'EOF'
pir 1.0
source "sum_pos.pir"

const @data: [i64; 6] = [3, -1, 4, -1, 5, -9]

fn @sum_pos(_0: &[i64; 6] "a", _1: i64 "n") -> i64 {
  let _2: i64 "s"
  let _3: i64 "i"
  let _4: bool
  let _5: bool
  let _6: bool
  let _7: bool
bb0:
  _2 = 0
  _3 = 0
  goto bb1
bb1:
  _4 = slt _3, _1
  br _4, bb2, bb5
bb2:
  _5 = ult _3, 6
  assert _5, bounds
  _6 = sgt (*_0)[_3], 0
  br _6, bb3, bb4
bb3:
  _7 = saddo _2, (*_0)[_3]
  assert !_7, overflow
  _2 = add _2, (*_0)[_3]
  goto bb4
bb4:
  _3 = add _3, 1
  goto bb1
bb5:
  return _2
}

fn @main() -> i64 {
  let _0: [i64; 6]
  let _1: &[i64; 6]
  let _2: i64
bb0:
  _0 = @data
  _1 = &_0
  _2 = call @sum_pos(_1, 6)
  return _2
}
EOF
$B/pir-opt --verify-only "${TMPDIR:-/tmp}/sum_pos.pir" && echo "verifies"
$B/pir-run "${TMPDIR:-/tmp}/sum_pos.pir"; echo "exit status $?"
$B/pebblec --from-pir --emit=llvm "${TMPDIR:-/tmp}/sum_pos.pir" -o - | sed -n '/^bb3:/,/^$/p;/^assert.ok1:/,/^}/p'

Output (complete):

verifies
exit status 12
bb3:                                              ; preds = %assert.ok
  %11 = load i64, ptr %s.addr, align 8
  %12 = load ptr, ptr %a.addr, align 8
  %13 = load i64, ptr %i.addr, align 8
  %14 = getelementptr inbounds [6 x i64], ptr %12, i64 0, i64 %13
  %15 = load i64, ptr %14, align 8
  %16 = call { i64, i1 } @llvm.sadd.with.overflow.i64(i64 %11, i64 %15)
  %17 = extractvalue { i64, i1 } %16, 1
  %18 = zext i1 %17 to i8
  store i8 %18, ptr %_7, align 1
  %19 = load i8, ptr %_7, align 1
  %20 = trunc i8 %19 to i1
  br i1 %20, label %trap.overflow, label %assert.ok1

assert.ok1:                                       ; preds = %bb3
  %32 = load i64, ptr %s.addr, align 8
  %33 = load ptr, ptr %a.addr, align 8
  %34 = load i64, ptr %i.addr, align 8
  %35 = getelementptr inbounds [6 x i64], ptr %33, i64 0, i64 %34
  %36 = load i64, ptr %35, align 8
  %37 = add i64 %32, %36
  store i64 %37, ptr %s.addr, align 8
  br label %bb4

trap.overflow:                                    ; preds = %bb3
  call void @pebble_trap(i32 1, ptr @.str, i32 0, i32 0)
  unreachable
}

What to notice: PIR is MIR-like (typed locals, places, assert terminators) and verifies; pir-run returns \(3 + 4 + 5 = 12\) as the exit status. The lowering is the memory form of Lesson 9.4 (every local an alloca, Proposition 9.8.9), and the checked addition becomes llvm.sadd.with.overflow plus a branch to a trap block, followed by a wrapping add without nsw (Proposition 9.8.12).

8. Comparison

Technique Power / precision Speed (asymptotic · practical) Output / error quality Implementation effort Typical use
LLVM IR SSA + phis, poison/UB semantics, GEP, full linkage model verification \(O(i + u + T_{\mathrm{dom}})\); heavy per-instruction objects readable .ll, precise verifier messages very large ecosystem clang, rustc, swiftc, most AOT compilers
GIMPLE SSA names + PHI over C-like types; UB from types comparable to LLVM dumps per pass (-fdump-tree-*) tied to GCC GCC's middle end
Swift SIL block arguments, Swift types, ownership linear verification -emit-sil, ownership diagnostics Swift-specific Swift's mandatory and performance passes
Rust MIR typed locals and places, explicit checks and unwinding, borrowck borrowck may be superlinear --emit=mir, borrow errors Rust-specific borrow checking, const eval, Miri
Cranelift IR SSA + block parameters, wrap-or-trap, integer addresses fast: built for JIT compile speed --emit-clif, verifier small, focused Wasmtime, rustc's Cranelift backend
WebAssembly stack machine, structured control, validated, sandboxed memory one-pass linear validation .wat/assembly text spec-defined portable distribution, browsers, sandboxes
PIR MIR-like locals/places/asserts, versioned text linear verification pir-opt, pir-run small (course) Pebble front ends → LLVM back end

Choose LLVM IR when you want LLVM's optimizer and back ends; choose a MIR/SIL/PIR-like level above it when your language needs checks, ownership or diagnostics that are easier on typed locals than on SSA; choose block arguments for a new SSA IR (MLIR, Cranelift) because edges become explicit; choose Wasm as a distribution format, not an optimization IR; choose Cranelift when compile time matters more than peak code quality.

9. Assessment

Technique Quiz ids (solutions/quizzes/ch09.yaml) Drill Flashcard tag Exercises
LLVM IR blockargs-translate, ir-coordinates ./course drill ir-validity ir-compare the whole lab
GIMPLE gimple-phi, ir-coordinates — (see below) ir-compare —
Swift SIL blockargs-translate, ir-coordinates — ir-compare —
Rust MIR mir-assert, ir-coordinates — ir-compare —
Cranelift IR clif-no-poison, blockargs-translate, ir-coordinates — ir-compare —
WebAssembly wasm-structured, ir-coordinates — ir-compare —
PIR mir-assert, ir-coordinates — ir-compare —

This lesson compares representations; its computational content (translating phis to block arguments, reading a dump) is covered by the quiz and by the "Try it" exercise, and the drills stay with LLVM IR.

Two different MIRs

Rust's MIR (mid-level IR, above LLVM IR) and LLVM's MIR (Machine IR, below it, Ch 21) share an acronym and nothing else.

References

See the chapter references.