Lesson 24.7 — What's next: MLIR, verified compilation, translation validation at scale¶
Techniques: MLIR dialect conversion — an IR made of dialects (sets of operations with their own verifiers), lowered progressively by legality-driven rewriting: a
ConversionTargetsays which operations are legal, patterns rewrite illegal ones, and the driver (OperationLegalizer) keeps going until everything is legal or nothing applies; CompCert (verified compilation) — a compiler whose every pass carries a machine-checked proof that the output simulates the input, composed into one theorem about the whole pipeline (transf_c_program_correct); translation validation at scale (Alive2) — instead of proving the compiler, check each run: encode source and target functions as SMT formulas and ask whether the target refines the source, which is whatalive-tvdoes to your-O1output below.
This course built one compiler, one way. This closing lesson shows the three directions in which the field has moved past that way: representing programs at several levels of abstraction at once (MLIR), proving the pipeline instead of testing it (CompCert), and checking each compilation with a solver when proving the compiler is out of reach (Alive2). Each is presented with the depth contract's full treatment, but the honest scope is a map: the exercises point to the lessons of this course that each direction generalizes (Ch 12's translation validation, Ch 13's Alive-style rule checking, Ch 11's lowering as a conversion), and the "Where to go from here" section of the chapter README says what to read next. mlir-opt and Coq are not in this container; the MLIR and CompCert boxes quote the pinned sources and LLVM's own test files. The Alive2 box is a real run of alive-tv (built by the Ch 12 author at commit 01a5ec45) on pebblec's IR.
1. Problem and motivation¶
MLIR dialect conversion¶
pebblec has two IRs: PIR, which knows about assert and checked arithmetic, and LLVM IR, which knows about i64 and br. Every real compiler has more: Clang has the AST, Swift has SIL, Rust has HIR, THIR and MIR, Flang had nothing between the parse tree and LLVM until MLIR. Each IR needs a parser, a printer, a verifier, a pass manager, and every optimization written for it is unavailable to the others. MLIR [LAB+21] makes one IR infrastructure host all of them: an operation is a generic record (name, operands, results, attributes, regions) and a dialect is a namespace of operations with their own verifier and folder. arith.addi, scf.for, llvm.add and a hypothetical pebble.assert are all operations in one module, at the same time. Lowering then is not a rewrite of one data structure into another but a conversion within the same one: mark pebble.* illegal and llvm.* legal, provide patterns, and let a driver rewrite until the module is legal. Pebble's Lesson 11.2 lowering scheme (PIR statement → LLVM instructions) is a set of conversion patterns; the "one function at a time, all statements" order of Algorithm 11.2.3 is the driver's job.
CompCert (verified compilation)¶
Chapter 12 tested pebblec with a fuzzer and validated single runs; Ch 24's Exercise E2 runs a differential fuzzer for hours and finds nothing. That is evidence, not proof: the fuzzer's generator (Lab L1) produces programs from a grammar a page long, and the bug that is not in that grammar is not found. CompCert [Ler09] is the demonstration that a proof is possible for a realistic compiler: C (a deterministic subset with a formal semantics) to PowerPC, ARM, x86, RISC-V assembly, through twenty passes, each with a Coq proof that the pass's output has no behavior the input does not allow. The proofs compose into one theorem, checked by Coq, and the compiler is extracted from the Coq definitions — the executable is the proved function. Csmith, the fuzzer of Yang et al. [YCER11] that found hundreds of bugs in GCC and LLVM, found none in the verified parts of CompCert.
Translation validation at scale (Alive2)¶
LLVM will not be rewritten in Coq. The alternative that scales is Ch 12's translation validation, made precise enough for real IR and fast enough to run over the whole test suite: Alive2 [LLH+21] gives LLVM IR a formal semantics (including undef, poison, memory, and the exact meaning of each nsw/nuw/exact flag), encodes a source and a target function into SMT, and asks the solver for an input on which the target's behavior is not one the source allows (Definition 12.8.2). Run over LLVM's test/Transforms directory, it has found dozens of miscompilations in InstCombine alone, several of them decade-old. Its tool alive-tv takes two .ll files; the box below runs it on the IR your own pipeline produces.
2. Definitions and algorithms¶
MLIR dialect conversion¶
Definition 24.7.1 (Operation, dialect, conversion target, legality)
An operation \(o\) has a name dialect.op, operands and results (SSA values with types), attributes,
and zero or more regions, each a list of blocks of operations (so operations nest: func.func holds
a region holding scf.for holding a region ...). A dialect \(D\) is a set of operation names with a
verifier. A conversion target \(T\) assigns to each operation name (or whole dialect) a legality
action \(\in \{\mathrm{Legal}, \mathrm{Dynamic}(\phi), \mathrm{Illegal}\}\): every instance is legal;
an instance is legal iff the predicate \(\phi(o)\) holds; no instance is legal. An operation may be
recursively legal: then everything nested in it is legal too. A conversion pattern \(P\) matches
an operation and rewrites it, receiving operands already remapped through a type converter (an
adaptor), and may fail. Partial conversion leaves untouched any operation that is neither illegal
nor reachable by a pattern; full conversion fails unless every operation is legal at the end
("Modes of Conversion" in [MLIR-DialectConversion]).
Algorithm 24.7.2 (Dialect conversion, after OperationLegalizer and applyPartialConversion)
- Input: a root operation \(R\), a target \(T\), a set of patterns \(\mathcal{P}\) with benefits, a type converter; the mode (partial or full).
- Output: the rewritten \(R\), or failure.
- Precondition: \(R\) verifies; every pattern is semantics-preserving on remapped operands.
- Postcondition: on success in full mode every remaining operation is legal for \(T\); in partial mode
every operation that was
Illegalhas been replaced, and the others are legal or untouched (Theorem 24.7.7). - Invariant: the IR seen by patterns is the original IR plus recorded, not yet applied, changes; a rolled-back pattern leaves no trace.
function ApplyConversion(R, T, P, mode):
W ← all operations under R in preorder # an op before the ops in its regions
for o in W: if not legalize(o):
if mode = full or T.isIllegal(o): fail
finalize: materialize pending type conversions, replace all uses, erase replaced ops
return success
function legalize(o): # OperationLegalizer::legalize
if o is ignored: return true
if T.isLegal(o):
if recursively legal: mark every op nested in o ignored
return true
if legalizeWithFold(o) succeeds and its results are legal: return true
for P in patterns for o, by decreasing benefit and legalization-graph cost:
apply P under a recording rewriter
if every newly created op legalizes (recursively): commit; return true
else rollback
return false
Cost: each operation is legalized at most once per pattern that creates it; the rollback makes the worst case exponential in the pattern-application depth, bounded in practice by a depth limit.
CompCert (verified compilation)¶
Definition 24.7.3 (Semantics, behaviors, refinement of behaviors)
A semantics \(L\) (CompCert's Smallstep.semantics) is a set of states, a step relation
\(s \xrightarrow{t} s'\) labelled by a trace \(t\) of observable events (calls to external functions,
volatile accesses), a set of initial states and a final-state predicate with a return code.
A program behavior (Behaviors.program_behavior) is one of: terminates with trace \(t\) and code
\(r\); diverges silently after trace \(t\); reacts with an infinite trace; goes wrong after \(t\)
(a stuck state: undefined behavior). \(\mathrm{beh}_1\) improves to \(\mathrm{beh}_2\)
(behavior_improves) iff they are equal, or \(\mathrm{beh}_1\) goes wrong after a trace \(t\) and
\(\mathrm{beh}_2\) has \(t\) as a prefix. A compiled program \(P_2\) refines \(P_1\),
\(L_1 \sqsupseteq L_2\) in the chapter's notation, when every behavior of \(L_2\) is improved from some
behavior of \(L_1\): what the target does is what the source does, except where the source had
undefined behavior. This is Definition 12.8.2 lifted from straight-line code to whole programs with
traces.
Definition 24.7.4 (Forward simulation with a well-founded measure)
A forward simulation from \(L_1\) to \(L_2\) (Smallstep.fsim_properties) is a relation
\(\sim \subseteq I \times \mathrm{state}(L_1) \times \mathrm{state}(L_2)\) indexed by a type \(I\) with a
well-founded order \(\prec\), such that:
- (initial) every initial \(s_1\) matches some initial \(s_2\): \(\exists i, s_2.\ s_1 \sim_i s_2\);
- (final) if \(s_1 \sim_i s_2\) and \(s_1\) is final with code \(r\), then \(s_2\) is final with \(r\);
- (step) if \(s_1 \xrightarrow{t} s_1'\) and \(s_1 \sim_i s_2\), then there are \(i'\), \(s_2'\) with \(s_1' \sim_{i'} s_2'\) and either \(s_2 \xrightarrow{t}{}^{+} s_2'\) (one or more steps), or \(s_2 \xrightarrow{t}{}^{*} s_2'\) (zero or more) and \(i' \prec i\).
The measure forbids the target from stuttering forever while the source runs: a pass that deletes
instructions (dead code, constant propagation) matches a source step with zero target steps only
finitely often in a row. forward_simulation_star, _plus, _step, _opt are the special cases
(Inlining's proof uses forward_simulation_star).
Translation validation at scale (Alive2)¶
Algorithm 24.7.5 (Validating one -O1 run of your pipeline, function by function)
- Input:
P.pbl; the course plugin;alive-tv. - Output: a verdict per (function, stage):
correct,incorrectwith a counterexample, orfailed-to-prove. - Precondition:
pebblec --emit=llvm -O0acceptsP.pbl; each stage is a function-level pipeline (no inlining, no function deletion), so that every function of the input is defined in the output. - Postcondition: a
correctverdict for \((f, S)\) is a proof of refinement of \(f\) by \(S(f)\) under the assumptions of Proposition 24.7.6; anincorrectverdict comes with an input that is a witness unless it exercises one of those assumptions. - Invariant: each stage is validated against its own input, so a bug is attributed to one stage.
function ValidateRun(P):
pebblec --emit=llvm -O0 P.pbl -o P0.ll
prev ← P0.ll
for S in the function-level stages of designCoursePipeline (pebble-o1<print> lists them):
opt -load-pass-plugin=PebblePasses.so -passes='<S>' prev -S -o P_S.ll
for f defined in prev:
alive-tv --func=f --disable-undef-input prev P_S.ll # read the Summary line
on failed-to-prove: retry with --smt-to=60000 or --src-unroll=k --tgt-unroll=k
prev ← P_S.ll
stop before the first interprocedural stage (pebble-inline)
Cost: one SMT query per (function, stage), seconds each for the straight-line code the generator of Lab L1 produces; minutes for loops, since Alive2 bounds them by unrolling.
Why stop at inlining: after it a callee may be gone, and Alive2 treats a call to a function it does not inline as an unknown call whose result in the source is unconstrained — the target, which computed the value, is then "less defined" and the tool reports a false alarm (the second box in §7).
Proposition 24.7.6 (What an Alive2 verdict means)
alive-tv reports correct for \((f_S, f_T)\) iff, for every input and every choice of
undef/nondeterminism in the source that the options allow, every behavior of \(f_T\) is a behavior of
\(f_S\) or \(f_S\) has undefined behavior on that input (refinement, Definition 12.8.2), under the
assumptions: loops are unrolled to the given bound; memory is modelled with the tool's finite block
model; calls to functions without bodies are the same unknown function on both sides. A correct
verdict is therefore a proof of refinement for the inputs and unrolling depth covered, and an
incorrect verdict comes with a concrete input that is a witness — unless it exercises one of the
assumptions (an unknown call, as in the box).
Proof
Alive2 encodes each function as a formula over symbolic inputs (Ch 12, Theorem 12.8.9 for
straight-line code; Alive2's translation extends it with a memory model and with undef/poison
as explicit non-determinism). The query it discharges is the negation of refinement: an input on
which the target has a behavior (value, memory state, UB, non-termination within the unrolled
bound) that the source does not allow. The encoding is exact for the fragment it models
[LLH+21, §3–4], so an unsatisfiable query is a proof of refinement within the model: bounded
loops, finite memory blocks, and unknown calls modelled by one uninterpreted function shared by both
sides. A satisfiable query yields a model, printed as the counterexample; when the model assigns
a behavior to an unknown call rather than a value to an input, the "witness" is an artifact of the
modelling assumption rather than of the program.
3. Worked example¶
MLIR dialect conversion¶
The LLVM test mlir/test/Conversion/ArithToLLVM/arith-to-llvm.mlir runs
mlir-opt -pass-pipeline="builtin.module(func.func(convert-arith-to-llvm))" and checks:
func.func @vector_ops(%arg0: vector<4xf32>, %arg1: vector<4xi1>, %arg2: vector<4xi64>, %arg3: vector<4xi64>) -> vector<4xf32> {
// CHECK-NEXT: %0 = llvm.mlir.constant(dense<4.200000e+01> : vector<4xf32>) : vector<4xf32>
%0 = arith.constant dense<42.> : vector<4xf32>
// CHECK-NEXT: %1 = llvm.fadd %arg0, %0 : vector<4xf32>
%1 = arith.addf %arg0, %0 : vector<4xf32>
// CHECK-NEXT: %2 = llvm.sdiv %arg2, %arg2 : vector<4xi64>
%3 = arith.divsi %arg2, %arg2 : vector<4xi64>
Trace of Algorithm 24.7.2 with target \(T\) = "llvm dialect legal, arith dialect illegal" (the pass's
ConvertArithToLLVMPass constructs exactly this and calls populateArithToLLVMConversionPatterns then
applyPartialConversion):
| Worklist op (preorder) | isLegal? |
Action | New ops, legalized recursively |
|---|---|---|---|
func.func @vector_ops |
not in \(T\), not Illegal → partial mode leaves it (it stays func.func; a later convert-func-to-llvm handles it) |
— | — |
arith.constant dense<42.> |
Illegal | pattern ConstantOpLowering |
llvm.mlir.constant — Legal |
arith.addf %arg0, %0 |
Illegal | pattern AddFOpLowering: operands remapped: %0 ↦ the new llvm.mlir.constant |
llvm.fadd — Legal |
arith.divsi |
Illegal | DivSIOpLowering |
llvm.sdiv — Legal |
func.return |
left (partial) | — | — |
The result is the mixed-dialect module the CHECK lines describe: func.func around llvm.*
operations. That mixture is the point: nothing forces the whole module to change level at once. The
same test also runs --convert-to-llvm="filter-dialects=arith", the generic driver that gathers the
patterns of every dialect implementing ConvertToLLVMPatternInterface. In Pebble terms, PIR's
assert c, kind @l:c (PIR spec §5) would be a pebble.assert op with one Illegal action and one
pattern producing llvm.cond_br + a call to pebble_trap, the exact rewrite of Algorithm 11.2.3's
statement case for assert.
CompCert (verified compilation)¶
driver/Compiler.v composes the RTL passes (CompCert 3.15):
Definition transf_rtl_program (f: RTL.program) : res Asm.program :=
OK f
@@ print (print_RTL 0)
@@ total_if Compopts.optim_tailcalls (time "Tail calls" Tailcall.transf_program)
@@ print (print_RTL 1)
@@@ time "Inlining" Inlining.transf_program
@@ print (print_RTL 2)
@@ time "Renumbering" Renumber.transf_program
@@ print (print_RTL 3)
@@ total_if Compopts.optim_constprop (time "Constant propagation" Constprop.transf_program)
@@ print (print_RTL 4)
@@ total_if Compopts.optim_constprop (time "Renumbering" Renumber.transf_program)
@@ print (print_RTL 5)
@@@ partial_if Compopts.optim_CSE (time "CSE" CSE.transf_program)
@@ print (print_RTL 6)
@@@ partial_if Compopts.optim_redundancy (time "Redundancy elimination" Deadcode.transf_program)
@@ print (print_RTL 7)
@@@ time "Unused globals" Unusedglob.transform_program
@@ print (print_RTL 8)
@@@ time "Register allocation" Allocation.transf_program
@@ print print_LTL
@@ time "Branch tunneling" Tunneling.tunnel_program
@@@ time "CFG linearization" Linearize.transf_program
@@ time "Label cleanup" CleanupLabels.transf_program
@@@ partial_if Compopts.debug (time "Debugging info for local variables" Debugvar.transf_program)
@@@ time "Mach generation" Stacking.transf_program
@@ print print_Mach
@@@ time "Asm generation" Asmgen.transf_program.
@@ composes a total pass, @@@ a partial one that may fail (res); the pipeline is Lesson 24.1's
staged design written as a function, with the same order the course pipeline uses (inline, then
constant propagation and CSE, then dead-code, then allocation), and with no fixed-point iteration: a
round would need a termination proof, so each pass runs once. For each pass there is a match_prog
relation and a proof file; for inlining, backend/Inliningproof.v defines match_states between an
RTL state of the source and one of the inlined program (an inlined call's frames are merged into
the caller's) and ends with
Theorem transf_program_correct:
forward_simulation (semantics prog) (semantics tprog).
Proof.
eapply forward_simulation_star.
forward_simulation_star is Definition 24.7.4 with a measure: a source Icall to an inlined function
is matched by zero target steps (the measure decreases), and a source step inside the inlined body by
one or more target steps.
Translation validation at scale (Alive2)¶
tv.pbl:
fn f(a: int, b: int) -> int {
let s = a &+ b;
let t = (s &* 8) / 2;
return t &- (b & 7);
}
fn main() -> int { print(f(3, 4)); return 0; }
Steps 1–3 of Algorithm 24.7.5 with \(S\) = the function-level passes of the canonicalize and
early-scalar stages plus pebble-strength and pebble-reassociate: the box in §7 shows the run and the
verdict Transformation seems to be correct! for f. What the solver had to check: that
mul i64 %s, 8 → shl i64 %s, 3 agrees on all \(2^{64}\) inputs (it does; no nsw on either side),
that sdiv i64 %x, 2 → (x + (x >>s 63) >>u 63) >>a 1 agrees, including on INT_MIN (it does: the
source's / is checked by PIR's assert, but the divisor 2 makes both checks br i1 false, which
pebble-constfold and pebble-sccp prove and simplifycfg did not yet remove — the trap blocks are
still there in both), and that the reordering of the and/sub is sound. Step 4's warning is the second
box: after pebble-o1 inlines f into pebble_main, validating pebble_main reports "Source is more
defined than target" with i64 %#0 = function did not return! — the source's call @f is unknown to
the tool, so it may not return, while the target prints 24. Not a bug; the tool's assumption.
4. Invariants and correctness¶
MLIR dialect conversion¶
Theorem 24.7.7 (Full conversion is sound and, with monotone patterns, terminates)
Let every pattern in \(\mathcal{P}\) be semantics-preserving (its output operations have the
meaning of the input on the remapped operands) and let each pattern's output be strictly closer to
legal in the legalization graph (computeLegalizationGraphBenefit's ordering: the minimal number of
pattern applications to reach only-legal operations decreases). Then Algorithm 24.7.2 in full mode
terminates, and if it succeeds the resulting module (i) contains only operations legal for \(T\) and
(ii) has the semantics of the input.
Proof
Termination: each legalize(o) either succeeds without changes, or applies a pattern whose newly
created operations have a strictly smaller distance to legality and are legalized recursively; by
induction on that distance the recursion is finite; on failure the rollback restores the previous
state and the next pattern is tried, and there are finitely many. The worklist is finite and no
operation is re-added except by creation, which is charged to the pattern that created it.
(i) In full mode the driver fails unless every operation on the worklist, including every created one,
was found legal or ignored under a recursively legal op; the ignored ones are legal by definition.
(ii) Every committed change is a pattern application or a fold, semantics-preserving by hypothesis
(folds are the dialect's own, verified per operation); rolled-back changes leave no trace, since the
rewriter replays them in reverse (RewriterState undo in ConversionPatternRewriterImpl). The type
materializations at finalization are identity conversions by the type converter's contract.
Invariant kept by the framework: during a conversion, operations are not erased and uses are not replaced immediately ("Immediate vs. Delayed IR Modification" in [MLIR-DialectConversion]); the rewriter records the intent and applies it at finalization, so that rollback is possible and patterns see the original operands through the adaptor.
CompCert (verified compilation)¶
Theorem 24.7.8 (Semantic preservation, after Compiler.transf_c_program_correct)
If transf_c_program p = OK tp, then backward_simulation (Csem.semantics p) (Asm.semantics tp):
every behavior of the compiled program tp is improved from a behavior of the source p
(backward_simulation_behavior_improves). In particular, if p has no undefined behavior, tp has
exactly p's behaviors, trace for trace.
Proof
(Sketch of the Coq proof's structure.) Each pass \(k\) proves forward_simulation between its input
and output semantics (Definition 24.7.4); compose_forward_simulations chains them into
forward_simulation (Cstrategy.semantics p) (Asm.semantics tp) (cstrategy_semantic_preservation),
where Cstrategy is C with a fixed evaluation order for the nondeterministic parts of C's semantics.
forward_simulation_behavior_improves turns a forward simulation into behavior improvement in the
forward direction: every source behavior is matched by some target behavior. To conclude about
every target behavior one needs the converse, and forward_to_backward_simulation gives it when the
source semantics is receptive and the target determinate (Asm.semantics_determinate): a
deterministic target has only one behavior per input, which the forward simulation already showed is
a source behavior. Finally c_semantic_preservation composes with the backward simulation from
nondeterministic Csem to Cstrategy, since choosing an evaluation order is itself a refinement.
The statement is checked by Coq; the executable compiler is extracted from the same definitions of
transf_c_program, so there is no gap between what was proved and what runs — except the
unverified parts, which Leroy lists: the parser (validated), the assembler and linker, and the
formal semantics themselves.
Why forward and not backward directly: a forward simulation is proved by case analysis on source
steps, which are the ones the pass author understands (what does Icall do after inlining?); a backward
simulation would need case analysis on target steps, including target states that correspond to no
source state. Determinism of the target lets one prove the easy direction and get the strong one.
Translation validation at scale (Alive2)¶
Proposition 24.7.6 is the invariant; its assumptions are the reason Algorithm 24.7.5 validates per
function and per function-level stage. The theorem behind it is Ch 12's Theorem 12.8.9 (soundness of the
SMT encoding) extended by Alive2's memory model and by the undef/poison semantics of Ch 13 Lesson 8.
5. Complexity¶
| Technique | Compile time | Proof / check effort | Trust base |
|---|---|---|---|
| Dialect conversion | per pattern application; rollback can repeat work | none (a framework) | the patterns' correctness, by test |
| CompCert | ordinary (no iteration; -O1-class code) |
~100 000 lines of Coq for 20 passes; years | Coq's kernel, the semantics, the extraction, the assembler |
| Alive2 | none in the compiler; SMT per (function, run) | none for the user; seconds–minutes per function | Alive2's encoding of LLVM semantics and the SMT solver |
6. Variants and refinements¶
- Greedy rewriting (
applyPatternsGreedily, thecanonicalizepass) is the other MLIR driver: no target, no legality, apply every matching pattern until a fixed point — Lesson 24.1's canonicalization loop with no phase ordering, relying on patterns that strictly simplify.Canonicalizer.cppbuilds aGreedyRewriteConfigand calls it; Ch 13's peephole is that driver specialized to one dialect. - One-shot bufferization,
transformdialect, PDL: MLIR's later additions make the schedule of conversions itself an IR (transform.apply_patterns), so that a pipeline is data, as Lesson 24.1'sPipelineDesignis. - CompCert's partial verification of register allocation (Rideau & Leroy 2010): the allocator is untrusted OCaml, and a verified validator checks each allocation — translation validation inside a verified compiler, the same idea as Alive2 with a proof of the validator instead of a solver.
- Bounded translation validation is what Alive2 does for loops (
--src-unroll); unbounded validation needs invariants (Lesson 12.8's discussion), and the research line "Alive2 → Alive-loops → LLVM's own-verify-each" is open. - Alive2 as a plugin:
opt -load-pass-plugin=tv.so -passes='tv,instcombine,tv'validates each pass of a pipeline against its predecessor, which is Algorithm 24.7.5 automated; LLVM's own buildbots run it over the InstCombine tests.
7. In real compilers¶
MLIR dialect conversion¶
A dialect conversion as LLVM's own test states it (quoted; no mlir-opt in this container)
Reproduce (LLVM 23.1.2 sources at tag llvmorg-23.1.2; the lines are quoted from the test file and the pass, not produced by running mlir-opt here):
curl -sL https://raw.githubusercontent.com/llvm/llvm-project/llvmorg-23.1.2/mlir/test/Conversion/ArithToLLVM/arith-to-llvm.mlir | sed -n '1,16p'
curl -sL https://raw.githubusercontent.com/llvm/llvm-project/llvmorg-23.1.2/mlir/lib/Conversion/ArithToLLVM/ArithToLLVM.cpp | grep -n 'applyPartialConversion\|populateArithToLLVMConversionPatterns\|struct ArithToLLVMConversionPass'
Output (the test's RUN lines and first checks; the pass's three key lines):
// RUN: mlir-opt -pass-pipeline="builtin.module(func.func(convert-arith-to-llvm))" %s -split-input-file | FileCheck %s
// Same below, but using the `ConvertToLLVMPatternInterface` entry point
// and the generic `convert-to-llvm` pass.
// RUN: mlir-opt --convert-to-llvm="filter-dialects=arith" --split-input-file %s | FileCheck %s
// RUN: mlir-opt --convert-to-llvm="filter-dialects=arith allow-pattern-rollback=0" --split-input-file %s | FileCheck %s
// CHECK-LABEL: @vector_ops
func.func @vector_ops(%arg0: vector<4xf32>, %arg1: vector<4xi1>, %arg2: vector<4xi64>, %arg3: vector<4xi64>) -> vector<4xf32> {
// CHECK-NEXT: %0 = llvm.mlir.constant(dense<4.200000e+01> : vector<4xf32>) : vector<4xf32>
%0 = arith.constant dense<42.> : vector<4xf32>
// CHECK-NEXT: %1 = llvm.fadd %arg0, %0 : vector<4xf32>
%1 = arith.addf %arg0, %0 : vector<4xf32>
// CHECK-NEXT: %2 = llvm.sdiv %arg2, %arg2 : vector<4xi64>
%3 = arith.divsi %arg2, %arg2 : vector<4xi64>
705:struct ArithToLLVMConversionPass
719: arith::populateArithToLLVMConversionPatterns(converter, patterns);
721: if (failed(applyPartialConversion(getOperation(), target,
748: arith::populateArithToLLVMConversionPatterns(typeConverter, patterns);
764:void mlir::arith::populateArithToLLVMConversionPatterns(
What to notice: the test runs inside func.func (builtin.module(func.func(...))), so the
function operation itself is never on the conversion's worklist; the CHECK-NEXT lines are the
trace of Algorithm 24.7.2 in §3, one llvm.* operation per arith.* one; and the third RUN line,
allow-pattern-rollback=0, is the framework's newer mode without the rollback of step 2.4 — the same
patterns must then succeed first time. The pass body is three calls: build a target, populate
patterns, applyPartialConversion.
MLIR (LLVM 23.1.2)
mlir/docs/DialectConversion.md — "Modes of Conversion" (partial, full, analysis; quoted in
Definition 24.7.1), "Conversion Target" (Legal, Dynamic, Illegal; recursive legality) [MLIR-DialectConversion];
mlir/lib/Transforms/Utils/DialectConversion.cpp — OperationLegalizer::legalize (Algorithm 24.7.2 step 2),
legalizeWithFold, legalizeWithPattern, legalizePatternResult, computeLegalizationGraphBenefit;
mlir/include/mlir/Transforms/DialectConversion.h — ConversionTarget, enum class LegalizationAction,
applyPartialConversion, applyFullConversion, applyAnalysisConversion;
mlir/lib/Conversion/ArithToLLVM/ArithToLLVM.cpp — ArithToLLVMConversionPass::runOnOperation
(builds the target, calls populateArithToLLVMConversionPatterns, then applyPartialConversion);
mlir/test/Conversion/ArithToLLVM/arith-to-llvm.mlir (the worked example). No mlir-opt in this
container: the test file's RUN and CHECK lines are quoted as the expected behavior.
- Flang (
flang/lib/Optimizer/CodeGen/CodeGen.cpp): FIR → LLVM dialect by one largeapplyFullConversion; IREE, CIRCT, Triton: whole compilers as stacks of conversions. - Swift's SIL and Rust's MIR are single-level IRs with their own infrastructure — the situation
MLIR was built to end. ClangIR (
clang/lib/CIR, 2024–) is Clang moving in MLIR's direction.
Find where LLVM does it. In mlir/lib/Transforms/Utils/DialectConversion.cpp, find OperationLegalizer::legalize. Question: in what order does it try (a) the target's legality check, (b) folding, (c) patterns, and what does it do to the operations nested in a recursively legal operation? (Quiz find-operation-legalizer.)
CompCert (verified compilation)¶
CompCert's simulation record and one pass's proof, as the Coq sources state them (quoted; no Coq in this container)
Reproduce (CompCert 3.15 sources at tag v3.15; quoted, not checked by running coqc here):
curl -sL https://raw.githubusercontent.com/AbsInt/CompCert/v3.15/common/Smallstep.v | sed -n '/^Record fsim_properties/,/^ }\./p'
curl -sL https://raw.githubusercontent.com/AbsInt/CompCert/v3.15/backend/Inliningproof.v | grep -n -A3 '^Theorem transf_program_correct'
curl -sL https://raw.githubusercontent.com/AbsInt/CompCert/v3.15/driver/Compiler.v | grep -n -A3 '^Theorem transf_c_program_correct'
Output (complete for the record; the two theorems with their first proof lines):
Record fsim_properties (L1 L2: semantics) (index: Type)
(order: index -> index -> Prop)
(match_states: index -> state L1 -> state L2 -> Prop) : Prop := {
fsim_order_wf: well_founded order;
fsim_match_initial_states:
forall s1, initial_state L1 s1 ->
exists i, exists s2, initial_state L2 s2 /\ match_states i s1 s2;
fsim_match_final_states:
forall i s1 s2 r,
match_states i s1 s2 -> final_state L1 s1 r -> final_state L2 s2 r;
fsim_simulation:
forall s1 t s1', Step L1 s1 t s1' ->
forall i s2, match_states i s1 s2 ->
exists i', exists s2',
(Plus L2 s2 t s2' \/ (Star L2 s2 t s2' /\ order i' i))
/\ match_states i' s1' s2';
fsim_public_preserved:
forall id, Senv.public_symbol (symbolenv L2) id = Senv.public_symbol (symbolenv L1) id
}.
1321:Theorem transf_program_correct:
1322- forward_simulation (semantics prog) (semantics tprog).
1323-Proof.
1324- eapply forward_simulation_star.
446:Theorem transf_c_program_correct:
447- forall p tp,
448- transf_c_program p = OK tp ->
449- backward_simulation (Csem.semantics p) (Asm.semantics tp).
What to notice: fsim_simulation is Definition 24.7.4 clause 3 verbatim: Plus (one or more
target steps) or Star with order i' i (zero or more, and the index decreases, fsim_order_wf).
Inlining's proof picks forward_simulation_star, the special case with a measure, because an
inlined call is a source step matched by no target step. The main theorem is stated for the
partial function transf_c_program (= OK tp) and concludes a backward simulation from
nondeterministic Csem, which Theorem 24.7.8's proof sketch traces back to the forward simulations.
CompCert 3.15
driver/Compiler.v — transf_rtl_program, transf_cminor_program, transf_c_program (the pass
list above), Theorem transf_c_program_correct (Theorem 24.7.8), cstrategy_semantic_preservation,
c_semantic_preservation, transf_c_program_match [CompCert-Compiler]; common/Smallstep.v —
Record fsim_properties (Definition 24.7.4, with fsim_order_wf, fsim_match_initial_states,
fsim_match_final_states, fsim_simulation), forward_simulation_star/_plus/_step/_opt,
compose_forward_simulations, forward_to_backward_simulation, Record determinate, Record receptive
[CompCert-Smallstep]; common/Behaviors.v — program_behavior, behavior_improves,
forward_simulation_behavior_improves, backward_simulation_behavior_improves;
backend/Inliningproof.v — match_states, Theorem transf_program_correct by
forward_simulation_star. No Coq in this container: quoted from the tagged sources.
- CakeML (HOL4): a verified compiler for an ML dialect, down to machine code including the garbage collector; seL4's C code is compiled by GCC and validated against the proof's semantics (Sewell, Myreen & Klein 2013) — translation validation of an unverified compiler's runs, per binary.
- Vellvm / VeLLVM-2 give LLVM IR a Coq semantics; Alive2 gives it an executable one.
Find where CompCert does it. In common/Smallstep.v, find fsim_properties. Question: in the fsim_simulation clause, what are the two alternatives for the target's steps when the source takes one step, and what role does order play in the second?
Translation validation at scale (Alive2)¶
alive-tv on your pipeline's output: correct for f, and the false alarm after inlining
Reproduce (pebblec from a solution build, opt 23.1.2, alive-tv at commit 01a5ec45 built against
LLVM 23.1.2, x86-64; tv.pbl is the program of §3):
pebblec --emit=llvm -O0 tv.pbl -o tv-O0.ll
opt -load-pass-plugin=build/ch24-sol/lib/PebblePasses.so \
-passes='function(pebble-mem2reg,pebble-constfold,pebble-peephole,pebble-lvn,pebble-strength,pebble-reassociate,pebble-dce)' \
-S tv-O0.ll -o tv-opt.ll
sed -n '/define.*@f/,/^}/p' tv-opt.ll
alive-tv --func=f --disable-undef-input tv-O0.ll tv-opt.ll | tail -7
opt -load-pass-plugin=build/ch24-sol/lib/PebblePasses.so -passes='pebble-o1' -S tv-O0.ll -o tv-o1.ll
grep -c '^define' tv-o1.ll
alive-tv --func=pebble_main --disable-undef-input tv-O0.ll tv-o1.ll | grep -v '^$' | sed -n '/^define/,/^}/p;/^Transformation/,/did not return/p'
Output (the function f after the stage, the first verdict's tail, and the second run abridged to the
two pebble_mains (source, then target) and the error):
define internal i64 @f(i64 %a, i64 %b) {
entry:
br label %bb0
bb0: ; preds = %entry
%0 = add i64 %a, %b
%1 = shl i64 %0, 3
br i1 false, label %trap.div_by_zero, label %assert.ok
assert.ok: ; preds = %bb0
br i1 false, label %trap.overflow, label %assert.ok1
trap.div_by_zero: ; preds = %bb0
call void @pebble_trap(i32 2, ptr @.str, i32 3, i32 13)
unreachable
assert.ok1: ; preds = %assert.ok
%2 = ashr i64 %1, 63
%3 = lshr i64 %2, 63
%4 = add i64 %1, %3
%5 = ashr i64 %4, 1
%6 = and i64 %b, 7
%7 = sub i64 %5, %6
ret i64 %7
trap.overflow: ; preds = %assert.ok
call void @pebble_trap(i32 1, ptr @.str, i32 3, i32 13)
unreachable
}
Transformation seems to be correct!
Summary:
1 correct transformations
0 incorrect transformations
0 failed-to-prove transformations
0 Alive2 errors
1
define i64 @pebble_main() {
entry:
%_0 = alloca i64 8, align 8
br label %bb0
bb0:
%#0 = call i64 @f(i64 3, i64 4)
store i64 %#0, ptr %_0, align 8
%#1 = load i64, ptr %_0, align 8
call void @pebble_print_int(i64 %#1)
call void @pebble_print_newline()
ret i64 0
}
define i64 @pebble_main() {
entry:
call void @pebble_print_int(i64 24)
call void @pebble_print_newline()
ret i64 0
}
Transformation doesn't verify!
ERROR: Source is more defined than target
Example:
Source:
ptr %_0 = pointer(local, block_id=0, offset=0) / Address=#x8000000000000000
>> Jump to %bb0
i64 %#0 = function did not return!
What to notice: the first run is a proof that seven of your passes, in sequence, refine f on
all \(2^{128}\) inputs, including the sdiv-by-2 strength reduction and the folded trap conditions.
The second run is the false alarm of Algorithm 24.7.5: f was inlined and its body is gone,
Alive2 models the source's call @f as an unknown function ("function did not return!" is one of
its allowed behaviors), and so the target, which always prints 24, is more defined. The
counterexample names no input, which is the tell. The differential fuzzer (E2) is the check that
covers the interprocedural stages; Alive2 covers the intraprocedural ones exhaustively.
Alive2 (commit 01a5ec45, built against LLVM 23.1.2)
tools/alive-tv.cpp — the driver: parses both modules, pairs functions by name (or --func),
calls Verifier::compareFunctions [Alive2]; llvm_util/llvm2alive.cpp — the translation of LLVM
IR into Alive2's IR (the assume i1 0 after a noreturn call in the box's dump is its rendering of
unreachable); ir/memory.cpp — the block-based memory model that made the @.str mismatch in a
first attempt (validating an llvm-extracted function against a module whose global has an
initializer reports "Mismatch in memory": always validate whole modules against whole modules).
Find where Alive2 does it. In tools/alive-tv.cpp, find the loop over the source module's functions. Question: how does it choose the target function to compare against each source function, and what does it do when --func is given?
8. Comparison¶
| Technique | Power / precision | Speed | Output / error quality | Implementation effort | Typical use |
|---|---|---|---|---|---|
| MLIR dialect conversion | legality-driven rewriting to a target dialect (Theorem 24.7.7) | pattern-driven; worklist with rollback | mixed-dialect IR at every stage | a framework: high once, low per lowering | Flang, IREE, CIRCT, Triton |
| CompCert (verified compilation) | a machine-checked proof for every compilation (Theorem 24.7.8) | the compiler runs normally; proofs are static | no miscompilation, by construction | very high (proof engineering) | safety-critical C |
| Alive2 (translation validation) | per-run refinement check, bounded loops (Proposition 24.7.6) | SMT time, seconds to minutes per function | counterexamples with inputs (the box) | none for the user; the tool exists | LLVM's own test suite, your pebble-o1 output |
| Differential fuzzing (E2, Ch 12) | whatever the generator reaches | thousands of programs per hour | a failing program, minimized | low (Lab L1) | every compiler's CI |
9. Assessment¶
- Quiz:
conversion-mode-outcome(mapping: which ops remain under partial conversion),legality-actions(mapping),find-operation-legalizer(text),compcert-pass-kind(mapping:@@vs@@@),fsim-measure-case(single),alive-verdict-meaning(single),alive-false-alarm(single). Tagsmlir,compcert,alive2. The exam is cumulative:../quiz.mdalso draws on every earlier chapter. - Drills: none. The three techniques are a framework, a proof, and a tool; the drill-able core of each is already a drill elsewhere:
pass-order(Lesson 24.1) for the conversion worklist's order, Ch 12'speephole-verifydrill for the refinement query, and Ch 13'srewrite-validitydrill for the encoding. - Flashcards: tags
mlir,compcert,alive2. - Exercises: E2's optional last step runs Algorithm 24.7.5 over the fuzzer's corpus (
fuzz.py --keep DIRkeeps the generated programs; a shell loop over them withalive-tv --func=<fn>records the verdicts); the ★ list in the README ("Where to go from here") has the two larger projects: PIR as an MLIR dialect, and a simulation argument for one of your passes.
Pitfall
"Alive2 said correct, so the pass is correct." It said that this input pair refines, for the
unrolling bound, under the tool's memory model, treating unknown calls as the same unknown on both
sides. A pass that is wrong only on a loop with 9 iterations, or only when a callee is not
nounwind, passes every alive-tv run that does not exercise that case. Proposition 24.7.6's
assumptions are the fine print; the fuzzer and the proof are the other two legs.
References¶
See the chapter references.