Lectures onType Theory
Chapter 134
Chapter 134Optional

A Certified Type-Preserving Compiler to Assembly

Prerequisites. Direct starred prerequisites: Chapter 133 and chapter 31. No later core chapter depends on this route.

The pipeline of chapter 133 maps a well-typed source program to a well-typed assembly program and says nothing about what either computes. To say something about what they compute, three things are needed that the previous chapter did not have: a semantics for the source language, a semantics for the assembly language, and a relation between them that survives five intermediate languages.

The first is easy and the third is the subject of this chapter. The second is where the difficulty starts. A source term of type Nat denotes a natural number. An assembly program denotes — what? It may diverge, so it does not denote a number. It reads and writes an untyped heap of machine words, so its meaning depends on a heap whose contents the type system has stopped describing. And it calls a runtime system that may move every object in that heap between two instructions. A semantics that is silent about any of these cannot support a preservation theorem, and a semantics that models all of them in the wrong way makes the theorem unprovable.

This chapter reconstructs Chlipala’s certified compiler (2007): six type-directed translations from the simply typed lambda calculus to an idealized assembly language, each with a denotational semantics and a machine-checked semantics-preservation proof, composed into one correctness theorem.

Syntax that is a typing derivation

Definition 134.1 — Intrinsically typed source syntax

Source types and terms are the indexed families sty:Set,SNat:sty,SArrow:stystysty,sterm:list stystySet, with constructors SVar:ΠΓ,Πt. var Γ tsterm Γ t,SLam:ΠΓ,Πd,Πr. sterm (d::Γ) rsterm Γ (SArrow d r),SApp:ΠΓ,Πd,Πr. sterm Γ (SArrow d r)sterm Γ dsterm Γ r,SConst:ΠΓ. Nsterm Γ SNat, where var Γ t is the family of de Bruijn indices witnessing that t occurs in Γ: it has First:ΠΓ,Πt. var (t::Γ) t and Next:ΠΓ,Πt,Πt. var Γ tvar (t::Γ) t.

An element of sterm Γ t is not a raw tree that a separate judgment later accepts. It is the typing derivation: there is no constructor producing an ill-typed term, and there is no typing judgment to state. That single decision determines the shape of every theorem below.

Proposition 134.2 — Type preservation has no content here

Let L1 and L2 be languages presented as in definition 134.1, and let T:ΠΓ,Πt. L1 Γ tL2 (FΓ) (Ft) be a total function of the metatheory, for some type translation F. Then T preserves typing.

Proof of Proposition 134.2 — Type preservation has no content here

Proof.T preserves typing” says: for every Γ, t and eL1 Γ t, the output lies in L2 (FΓ) (Ft). That is the codomain of T. A function inhabits its stated type, so the statement is discharged by the fact that T is well typed. ◻

This is not a trick. It relocates work rather than removing it: the type preservation lemmas of chapter 133 become obligations discharged while writing each translation, and what remains to be proved is the part those lemmas never touched — that the translation preserves meaning. Section 134.3 shows the price.

Definition 134.3 — Denotation of the source language

Interpret types and contexts by [[SNat]]=N,[[SArrow t1 t2]]=[[t1]][[t2]],[[Γ]]=[[t1]]××[[tn]], and a term esterm Γ t by a function [[e]]:[[Γ]][[t]]: [[SVar v]]σ=σ(v),[[SConst n]]σ=n,[[SLam e]]σ=λx.[[e]](x,σ),[[SApp e1 e2]]σ=([[e1]]σ)([[e2]]σ).

The object language’s binder is interpreted by the metalanguage’s binder. This is what makes the semantics usable in a proof assistant: no environment manipulation, no substitution lemma, and — as section 134.5 shows — no need to say what a closure is.

Linearization and its composition operator

Definition 134.4 — Linear

o::=nxλx:τ.e,e::=let x=o in ethrow oxyz. A Linear term is a sequence of bindings of primitive operands, ending either in a throw to the current continuation or in a function call; a function takes its ordinary argument and its continuation. Its denotation takes a substitution and a continuation: [[e]]:[[Γ]]([[τ]]R)R.

Definition 134.5 — Splicing

For Linear terms e1 in Γ and e2 in τ::Γ, the term e1ue2 runs e1 and binds its result to u in e2: (let y=o in e1)ue2=let y=o in (e1ue2),(throw o)ue2=let u=o in e2,(xyz)ue2=let f=(λv.let g=(λu.e2) in zvg) in xyf. Linearization is then [[n]]L=throw n,[[x]]L=throw x,[[λx:τ.e]]L=throw λx:τ.[[e]]L, and, for an application, [[e1e2]]L=[[e1]]Lu([[e2]]Lv(let f=(λx.throw x) in uvf)).

Lemma 134.6 — Splicing is sound

For all Γ, t, elterm Γ t, t, elterm (t::Γ) t, every substitution σ and every continuation k, [[eue]]σk=[[e]]σ(λx.[[e]](x,σ)k).

Proof of Lemma 134.6 — Splicing is sound

Proof. Induction on e. Throw. Both sides reduce to [[e]]([[o]]σ,σ)k: the left by unfolding the let clause of the Linear denotation on let u=o in e, the right by unfolding the throw clause [[throw o]]σk=k([[o]]σ) and applying the displayed k.

Let. With e=let y=o in e1, unfolding the let clause on both sides leaves [[e1ue]]([[o]]σ,σ)k on the left and [[e1]]([[o]]σ,σ)(λx.[[e]](x,σ)k) on the right — except that e is now used under one more binder, so the right-hand side is really [[e]](x,([[o]]σ,σ))k. The induction hypothesis closes the case exactly when the two readings agree, which is lemma 134.8 below.

Call. With e=xyz, the left side unfolds the two let-bound abstractions and then the call clause [[xyz]]σk=σ(x)σ(y)σ(z), giving σ(x)σ(y)(λv.σ(z)v(λu.[[e]](u,σ)k)). The right side is σ(x)σ(y)σ(z) applied to λx.[[e]](x,σ)k; the two agree because the metalanguage function σ(z) is applied to the same two arguments in both. ◻

Theorem 134.7 — Linearization is sound

For every Γ, t and esterm Γ t, every σ and every k, [[[[e]]L]]σk=k([[e]]σ).

Proof of Theorem 134.7 — Linearization is sound

Proof. Induction on e. Constant and variable unfold the throw clause directly. Abstraction unfolds the throw clause and then the induction hypothesis at the body, under the metalanguage binder. Application applies lemma 134.6 twice, then the two induction hypotheses, then the call clause; the residual continuation λx.throw x denotes the identity, which is what leaves k applied to ([[e1]]σ)([[e2]]σ). ◻

The price of intrinsic typing

In the application clause of definition 134.5, the subterm [[e2]]L was built in Γ and is used in u:τ1::Γ. Mathematically this is harmless. With intrinsic syntax it is a type error: the term inhabits lterm Γ t and the position demands lterm (τ1::Γ) t. A coercion is required, and the coercion is a function that renumbers de Bruijn indices: weakenFront:ΠΓ,Πτ. lterm Γ τΠτ. lterm (τ::Γ) τ. This function is the weakening lemma “if Γe:τ then Γ,x:τe:τ when x is not free in e”, written as a program. Since it is a program and not a lemma, it must be accompanied by the lemma that it does not change meaning.

Lemma 134.8 — Weakening is denotation-preserving

For every elterm Γ τ, every τ, every x[[τ]] and every σ[[Γ]], [[weakenFront e τ]](x,σ)=[[e]]σ.

Proof of Lemma 134.8 — Weakening is denotation-preserving

Proof. Induction on e, with the strengthened statement that for every insertion position i the corresponding weakening satisfies the same equation. The variable case is the point: weakenFront sends First to Next First and Next v to Next (Next v), and the tuple (x,σ) projects at Next v exactly as σ projects at v. The binding cases insert at position i+1 in the extended context and appeal to the strengthened hypothesis; the remaining cases are componentwise. ◻

Remark 134.9 — Why this is generated, not written

Each of the six languages below needs its own weakenFront, its own strengthening and substitution functions, and its own copy of lemma 134.8. None of them depends on anything specific to the language beyond its list of constructors and their binding structure. In the frozen development they are produced by a generic programming layer, together with the proofs that they commute with an arbitrary compositional denotation function. That layer, and not the compiler, is the bulk of the development.

Exercise 134.1

★☆☆ Write weakenFront for the variable family var, and check the two cases of lemma 134.8 for it. Say what goes wrong if weakenFront is defined to send First to First.

Divergence and the trace domain

Definition 134.10 — The lower languages

Three further languages complete the pipeline. For CPS, τ::=NatτN,o::=nxλx:τ.e,e::=let x=o in exy. For CC, τ::=ττ×τN,o::=x,yπix,e::=let x=o in exy,p::=let x=(λy:τ.e) in pe. For Alloc, τ::=NrefτN,o::=nxnnew xπix,e::=let x=o in exy,p::=let (λy:τ.e) in pe. CC hoists every function to the top level and replaces anonymous functions by closures x,y of a code pointer and an environment. Alloc makes allocation explicit, numbers the code blocks, and — the first real loss — replaces every record type by the single type ref.

Scoping keeps CC terminating: a code block may call only blocks defined before it. Alloc has code pointer constants n, so a block may call itself, and Alloc programs may diverge. A denotational semantics must therefore change domain exactly here.

Definition 134.11 — Traces

Let T be the largest set generated by T::=n,T, that is, the coinductive type of possibly infinite sequences of ending, if at all, in a natural number or in . Write Tn when T is a finite sequence of followed by n. A nonterminating program denotes an infinite sequence of ; a program that crashes denotes a finite sequence ending in .

Using a coinductive stream rather than a domain-theoretic least fixed point is a decision about what can be constructed in the metatheory, not about what divergence means. It buys a definition that a total type theory accepts, at the cost that the number of s is observable: two programs that return the same answer after different numbers of calls have different denotations.

Definition 134.12 — Heaps, tags and failure

Let C={Traced,Untraced}×N and M=list(listC): a heap is a list of records, each field a tag together with a machine word. The tag of a type is tagof(N)=Untraced, tagof(ref)=Traced, tagof(τN)=Untraced. Operands denote heap transformers that may fail: [[n]]σm=(m,n),[[x]]σm=(m,σ(x)),[[n]]σm=(m,n+1),[[new x]]σm=(m[σ(x)],|m|), and projection may fail: [[πix]]σm={(m,v)if mσ(x),i=(tagof(τ),v),otherwise.

The in the projection clause is the compensation for the type information Alloc discarded. A well-typed Alloc program never reaches it — that is part of what the preservation theorem for the CC-to-Alloc pass says — but the semantics is defined for every Alloc program, including the ones the translation cannot produce.

Exercise 134.2

★★☆ Write an Alloc program that allocates a two-field record holding a number and a code pointer, and then projects the first field expecting a ref. Compute its denotation and identify the clause that yields . Then say why no CC program translates to it.

Closure conversion by a logical relation

Definition 134.13 — The CPS–CC relation

Write P and C for the CPS and CC denotations. Define, by recursion on the CPS type, n1Nn2iffn1=n2,f1τNf2iffx1[[τ]]P,x2[[τ]]C. x1τx2f1x1=f2x2.

This is the ordinary logical relation for the simply typed lambda calculus, with no clause for closures at all. The reason is worth stating exactly, because it is the chapter’s one genuinely surprising step.

Remark 134.14 — Where the closure went

A CC code pointer has type τ1×τ2N, whose denotation is T1T2N with T1 the denotation of the environment type. The translation of a CPS abstraction emits a fresh code block and immediately applies its denotation to the environment built from the abstraction’s free variables. That partial application has denotation T2N, which is exactly the shape definition 134.13 expects at τ2N. The packaging of code with environment has been performed by a metalanguage function that already captures its free variables, so the relation never mentions it. Under an operational semantics the same proof needs existential types and a relation on packages.

Theorem 134.15 — Closure conversion is sound

For every CPS term e in context Γ and every pair of substitutions related pointwise by definition 134.13, [[e]]Pσ1 and [[[[e]]C]]Cσ2 are related at the type of e.

Proof of Theorem 134.15 — Closure conversion is sound

Proof. Induction on e, unfolding definition 134.13 at each type. The abstraction case is remark 134.14: the induction hypothesis relates the body under a substitution extended by related arguments, and the partial application of the emitted code block to the environment is definitionally the metalanguage function the hypothesis produced. The application case instantiates the relation at the argument, which the hypothesis for the argument supplies. The variable, constant and let cases project or extend the substitution and reapply the hypothesis. ◻

A moving heap

The last translation targets an assembly language whose new instruction is provided by a runtime system. That system may relocate every object in the heap on every allocation, provided it relocates the roots consistently. The proof of the last pass therefore needs a theorem saying that a well-typed program cannot observe the relocation.

Definition 134.16 — Pointer isomorphism

Fix heaps m1,m2M. Two words w1,w2 are isomorphic when the records m1,w1 and m2,w2 have the same length, agree field by field on tags, agree on the data of every Untraced field, and have isomorphic data in every Traced field.

Theorem 134.17 — Heap rearrangement safety

Let Δ be a register typing, m1,m2 heaps and R1,R2 register files such that

  1. for every register r with Δ(r)=ref, R1(r) is isomorphic to R2(r) with respect to m1 and m2; and

  2. for every register r with Δ(r)ref, R1(r)=R2(r).

If p is a Flat program with Δp, then [[p]]R1m1=[[p]]R2m2.

Read the conclusion carefully: the two sides are traces, so the theorem says that the program returns the same result, if any, and makes the same number of function calls, from any two isomorphic starting states. It is what makes the runtime system’s freedom to move objects invisible, and it is the hypothesis under which the Flat-to-assembly pass is proved. Its proof is a coinduction on the trace, with an inner induction on the instruction sequence maintaining the isomorphism through every load, store and allocation; the register typing Δ is what tells the argument which registers are roots.

Exercise 134.3

★★☆ Drop hypothesis 2 of theorem 134.17 and give two states and a Flat program that distinguishes them. Then explain why the corresponding counterexample cannot be built when hypothesis 2 holds but the two heaps are merely permutations of one another.

The composed theorem, and its exact shape

Theorem 134.18 — Compiler correctness

Let m be a heap initialized with a closure for the top-level continuation, let p point to that closure, and let R map the first register to p. For every Source term e with e:Nat, [[C(e)]]Rm  [[e]](), where C is the composite of the six translations.

Proof of Theorem 134.18 — Compiler correctness

Proof. Compose the soundness theorem of each pass. Each is stated as a relation between the denotation of a term and the denotation of its translation: theorem 134.7 for linearization, its analogue for the second CPS stage, theorem 134.15 for closure conversion, the analogues for Alloc and Flat, and the last pass under the hypotheses of theorem 134.17. At the top level the relation at type Nat is equality of natural numbers, which turns the chain of relations into the displayed termination statement. ◻

Remark 134.19 — Why the theorem is stated only at Nat

One might hope for a correctness theorem at every source type, obtained by composing the per-pass results parametrically. That composition fails. The semantics is not fully abstract: the denotations of two passes’ target languages contain metalanguage functions that are not denotations of any term, and equality of denotations at a function type quantifies over all of them. What composes instead is a weaker relation requiring functions to agree only at arguments that are denotations of terms — the move made by pre-logical relations. Theorem 134.18 is stated at Nat because that is the type at which the weaker relation collapses to equality.

Definition 134.20 — The trusted computing base

What must be trusted to believe theorem 134.18 for a particular output program, beyond the proof assistant, its kernel, and the toolchain and hardware that run them, is the code reached by a backward slice from the statement of that program’s correctness theorem: in the frozen development, about two hundred lines. To believe the compiler rather than one of its outputs, add the formalization of the source language, about a hundred lines more. Outside that slice lie the extraction to a functional language, the runtime system providing new, and the assembler; none is verified, and theorem 134.17 is precisely the interface through which the runtime system’s behaviour is constrained.

Limits and seminar

The frozen source is Chlipala (2007) and its accompanying Coq development. The six languages of definition 134.1, definition 134.4, definition 134.10, the splicing operator, the trace domain, the tagged heap, the CPS–CC logical relation, theorem 134.17 and theorem 134.18 are its definitions and theorems, retained at their exact hypotheses. Proposition 134.2 and lemma 134.8 are local; the proofs written out here are local reconstructions, and the development’s own proofs are machine-checked scripts that discharge most cases by type-directed search rather than by the case analyses displayed above.

Five boundaries. The source language is the simply typed lambda calculus with natural numbers; there is no polymorphism, no recursion, no data other than functions and numbers, and therefore no source-level nontermination. The assembly language is idealized: unbounded registers, an abstract heap of records, and no instruction encoding. The theorem is an equality of traces, so it counts function calls; a pass that changed the number of calls would not satisfy it even if it preserved results. The theorem is stated at type Nat only, for the reason in remark 134.19. And the runtime system is a parameter constrained only by the isomorphism condition of theorem 134.17; no garbage collector is verified here, and nothing above says that any particular collector satisfies that condition.

Against chapter 133, the contrast is exact. That chapter proved type preservation for five passes and, by its own proposition, nothing about behaviour. This chapter proves semantics preservation for six passes and gets type preservation for free, by proposition 134.2 — at the cost of a source language with far less in it, and of the generic infrastructure of remark 134.9.

[4]

Suggested first pass.

None of these problems is a prerequisite for a later chapter. Begin with exercise 134.4, then complete exercise 134.7.

Exercise 134.4

★★☆ Linearize (λx:Nat.x)3 using definition 134.5, writing every splice in full. Then verify theorem 134.7 on the result by computing both sides at the identity continuation, naming the clause used at each step.

Exercise 134.5

★★☆ Give an Alloc program whose denotation is an infinite sequence of , and one whose denotation is a finite sequence ending in . Then give two Alloc programs that return the same answer but have different denotations, and say which hypothesis of theorem 134.18 would fail if one were compiled to the other.

Exercise 134.6

★★★ Redo definition 134.13 for an operational semantics: define a step relation for CPS and for CC, and state the logical relation that theorem 134.15 would need. Identify the clause that must mention existential packages, and explain in two sentences which feature of the denotational setting removed it.

Exercise 134.7 — Practical: intrinsic syntax and splicing

★★★ Practical project.ctpc-intrinsic-splice Complete project ctpc-intrinsic-splice. Implement, for the source and Linear languages of definition 134.1, definition 134.4, (i) a type checker for the source language together with a builder interface whose application constructor is defined only when its two arguments already agree at the domain type, so that an ill-typed application cannot be built through the interface, (ii) an evaluator for the source language and one for Linear terms in which a throw returns the thrown value and a call runs the callee and throws its result to the continuation, (iii) weakenFront as an index shift and the splicing of definition 134.5, and (iv) a checker for the equations of lemma 134.8 and lemma 134.6 on named inputs. The invariant the implementation must maintain is that linearization is total on terms the builder accepts. If the implementation language has indexed inductive families, build the syntax intrinsically instead and delete the builder interface; if it does not, record that the type-preservation reading of proposition 134.2 is not modelled. The named cases print

identity-app: 3
linearized: 3
weaken-preserves: ok
splice-sound: ok
ill-typed-app: unrepresentable

The checker is independent evidence on the named inputs. It does not prove lemma 134.6 or lemma 134.8, implements none of the four lower languages, and contains no trace domain, heap or runtime system. One of its splice cases must have a second term that mentions a variable of the outer context; without such a case the renumbering step of definition 134.5 is not exercised at all.

Search the book

Type to search the local edition.