Example assembly/machine instruction from lambda calculus

Viewed 740

I'm learning a bit of lambda calculus and one of the things that I'm quite curious about is how the totally-abstract functions might actually be applied in instructions. Let's take the following example, where I'm allowing small natural numbers (as a given) and a TRUE FALSE definition.

For example, let's use the following code, which should evaluate to 5:

# using python for the example but anything would suffice
TRUE = lambda x: lambda y: x      # T = λab.a
FALSE = lambda x: lambda y: y     # F = λab.b
TRUE(5)(2)                        # T('5')('2')

How might this be implemented in a lambda-calculus-like instruction to evaluate this? One thing I thought of is to "un-lambda-ify" it, so it just comes out to something like:

// uncurried
int TRUE(int x, int y) {
    return x;
}
int FALSE(int x, int y) {
    return y;
}
TRUE(2, 5);

Which might come out to something along the lines of:

SYS_EXIT = 60
.globl _start

TRUE:                      # faking a C calling convention
    mov %edi, %eax
    ret

_start:
    mov $5, %edi
    mov $2, %esi
    call TRUE
    mov %eax, %edi
    mov $SYS_EXIT, %eax
    syscall

Is that how this is done, or what's a closer explanation of how a functional/lambda-like language might be compiled into instructions?

2 Answers

There are many evaluation and compilation strategies for functional languages based on the lambda calculus. For simplicity, I'll assume you're talking about how one would treat your example as if it were really a strict functional language (where we have applicative-order call-by-value application and a suitable encoding of integer literals).

If we consider the program you've given:

(λx.λy.x) 5 2

The first step is usually to perform some kind of unique-renaming where we ensure lexical scope is apparent (as you may know, λx.λx.x is alpha-equivalent to λy.λx.x - although the latter is more clear) and that potential code motion optimisations don't accidentally lead to scoping conflicts. The program above already has unique names so I'll avoid it as a step here.

The second step is to perform some kind of conversion into a normal form representation (where intermediary values produced by computations become bound to variables given unique names). For strict functional languages, generally one decides between ANF (A Normal Form) and CPS (Continuation Passing Style). I'll go with a variant of ANF for simplicity (you can find thorough treatment of the ideas of CPS compilation in Andrew Appel's "Compiling with Continuations" if you're interested).

Here's a potential result of this kind of transformation:

let f x = 
  let g y = x in g
in
let x0 = f 5 in
let x1 = x0 2 in
x1

As you can see, the previously-anonymous functions (lambda abstractions) are now represented by named functions and the intermediary results of the two applications are now bound to variables. There are quite a few transformations one can do within this form but this answer focuses more on introducing this representation for structural clarity rather than any optimisations we could apply.

The next step is to perform closure conversion. This step addresses a problem that occurs when compiling code with higher-order functions whose bodies capture values outwith their own lexical scope. Since we have not performed an uncurrying transformation, a partial application is present in our code (f 5). This must return a function that, when applied, returns the x we provided to f (regardless of the scope). To solve this without runtime code generation, compiler developers opt to represent these higher-order functions using a "closure" data structure (so-called because it closes over the function by providing values to free variables).

The transformation itself adapts functions to take a supplementary argument (to their environment) and the returning of higher-order functions to return a closure data structure (in our case we'll use a "flat" closure: a pair consisting of a code pointer and a pointer to an environment that will be represented in memory by a record). On top of this, all call sites must be adapted to destructure the returned closure in order to apply it.

That sounds like a lot but here's some pseudo-code to demonstrate this transformation performed naively:

let f(x, e) =
  let g(y, e') = e'.x in (g, {x})
in
let x0 = f(5, {}) in
let g_ptr = x0.0 in
let g_env = x0.1 in
let x1 = g_ptr(2, g_env) in
x1

Here, I've used brace syntax to represent the construction of environments (so {} is an empty environment - an artefact left over from this naive transformation - and {x} represents a heap-allocated record storing the value of x). The pair syntax (g, {x}) heap-allocates a pair (as a structure) where the first component is the code pointer for g and the second component is the pointer to the heap-allocated record for {x}. The .0 and .1 projections represent accessing these components. You may also notice that I've adopted multi-argument application syntax f(x,y,...) - this should not be confused with the syntax I've used for pairs.

After closure conversion, the program is closed and, therefore, we can safely perform the so-called "hoisting" transformation where we lift nested functions into the global scope (so they appear more like C-like functions which are simpler to compile - a lot of the compilation process is a kind of linearisation).

The result of hoisting could look like:

let g(y, e') = e'.x 

let f(x, e) = (g, {x})

let entry() =
  let x0 = f(5, {}) in
  let g_ptr = x0.0 in
  let g_env = x0.1 in
  let x1 = g_ptr(2, g_env) in
  x1

From here, generally compilers may go to some kind of three-address code IR that has some notion of aggregate and pointers types (to conveniently deal with the auxilliary structures introduced by closure conversion). It is entirely possible to lower the above representation into LLVM (with a few cheap i64* casting tricks - notice that each function is conveniently 2-argument so we don't need to book-keep any typing information to produce a call). As an exercise, you may wish to convert the above into C code (which is also straightforward). But, since your answer wanted some assembly, here's a very naive (leaking) implementation:

    .data
fmt:    .asciz "result = %lld\n"
    
    .text
    .globl main
g:
    mov 0(%rsi), %rax # get and return x from environment
    ret
    
f:
    sub $24, %rsp
    mov %rdi, (%rsp)  # preserve x
    mov $8, %edi      # allocate space for {x}
    call malloc@plt
    mov (%rsp), %rcx  # load and store x into environment
    mov %rcx, 0(%rax)
    mov %rax, 8(%rsp) # preserve environment
    mov $16, %edi     # allocate closure pair (2 pointers)
    call malloc@plt
    lea g(%rip), %rcx
    mov %rcx, 0(%rax) # store g's code pointer as first component
    mov 8(%rsp), %rcx
    mov %rcx, 8(%rax) # store g's environment, {x}, as second component
    add $24, %rsp
    ret
main:
    push %rbx
    mov $5, %edi
    xor %esi, %esi    # {} = null, for simplicity
    call f            # f(5, {})
    mov 0(%rax), %rbx # extract code pointer
    mov 8(%rax), %rsi # extract environment
    mov $2, %edi
    call *%rbx        # g(2, {x})
    # print the result
    mov %rax, %rsi
    lea fmt(%rip), %rdi
    xor %eax, %eax
    call printf@plt
    xor %eax, %eax
    pop %rbx
    ret
    

Now I'll go into some important, practical, details that I neglected to mention throughout this answer:

  • The "extent" (or lifetime) of a closure cannot always be deduced so, by default, we choose to allocate them on the heap. If we can deduce that a closure does not escape upwards (a process known as "escape analysis" is used to discover these cases), we can be more economical about where we allocate these closures (such as on the stack). The cases we want to optimise for are where closures only escape downwards (in other words, a higher-order function is only passed down the call-stack but never escapes upwards - in the call-chain's result - or by being assigned to some other location that outlives the closure's creation site).
  • The representation of a closure's environment as a record is very important as we'll be relying on garbage collection to collect them. This has a huge bearing on the representations we choose for how we represent unboxed literal values (such as the 5 and 2 in the above example) alongside pointers to heap-allocated things (such as closure environments that, themselves, store both integer literals and pointers to closure pairs). The way that languages such as OCaml choose to do this is to perform least-significant-bit tagging (by exploiting the alignment of pointers, we can differentiate between unboxed 63 bit integers and 64 bit pointers by checking the lowest bit - this does, however, mean that all arithmetic must be adapted to work on values that are shifted left by 1 place and incremented by 1; so 34 looks like 69 in compiled OCaml programs). On top of this differentiation, garbage collectors must traverse the data structures to find pointers and so we require a fairly homogeneous structure to efficiently do this without compiling in layout information (hence records have strict alignment requirements and store 64 bit values - you can see a diagram of the homogeneous "block" layout used by OCaml here).

I hope this answer gives a kind of understanding of how strict, functional, languages that find their roots in the lambda calculus are generally tackled.

To summarise, the steps are basically:

  • Unique renaming (scope-checking can be done at the same time)
  • Selective or whole-program transformation into ANF or CPS (involves creating fresh names)
  • ANF or CPS-specific optimisations (general things like uncurrying, inline expansion, dead code removal, constant folding, tuple argument flattening, etc.)
  • Closure conversion (followed, potentially, by elimination strategies such as lambda lifting of known closures, fix-minimisation using SCCs, etc.)
  • Hoisting
  • Further lowering into a more machine-like three (or two) address code IR that gives more explicit detail to treatment of auxiliary structures introduced by transformations
  • Lowering of that IR into target-specific assembly language

This is definitely not the whole story when it comes to compilation of strict, functional, languages. For example, if we extended our language to have named, mutually-recursive, functions, it would be desirable to work closure sharing into our closure conversion transformation (and also eliminate the cases where closures aren't necessary).

This question is really too big for Stack Overflow.

But one way of answering it is to say that if you can mechanically translate some variant of λ-calculus into some other language, then you can reduce the question of how you might compile λ-calculus to asking how do you turn that substrate language into machine language for some physical machine: how do you compile that substrate language, in other words. That should give an initial hint as to why this question is too big.

Fortunately λ-calculus is pretty simple, so the translation into a substrate language is also pretty easy to do, especially if you pick a substrate language which has first-class functions and macros: you can simply compile λ-calculus into a tiny subset of that language. Chances are that the substrate language will have operations on numbers, strings, and all sorts of other types of thing, but your compiler won't target any of those: it will just turn functions in the λ-calculus into functions in the underlying language, and then rely on that language to compile them. If the substrate language doesn't have first-class functions you'll have to work harder, so I'll pick one that does.

So here is a specific example of doing that. I have a toy language called 'oa' (or in fact 'oa/normal' which is an untyped, normal-order λ-calculus. It represents functions in a slight variant of the traditional Lisp representation: (λ x y) is λx.y. Function application is (x y).

oa then get gets turned into Scheme (actually Racket) roughly as follows.

First of all there are two operations it uses to turn normal-order semantics into Scheme's applicative-order semantics:

  • (hold x) delays the evaluation of x – it is just a version of Scheme's delay which exists so I could instrument it to find bugs. Like delay, hold is not a function: it's a macro. If there were no macros the translation process would have to produce into the expression into which hold expands.
  • (release* x) will force the held object made by hold and will do so until the object it gets is not a held object. release* is equivalent to an iterated force which keeps forcing until the thing is no longer a promise. Unlike hold, release* is a function, but as with hold it could simply be expanded out into inline code if you wanted to make the output of the conversion larger and harder to read.

So then here is how a couple of λ-calculus expressions get turned into Scheme expressions. Here I'll use λ to mean 'this is a oa/λ-calculus-world thing' and lambda to mean 'this is a Scheme world thing'.

(define true (λ x (λ y x)))
(define false (λ x (λ y y)))

turns into

(define true (lambda (x) (lambda (y) x)))
(define false (lambda (x) (lambda (y) y)))
(define cond (λ p (λ a (λ b ((p a) b)))))

turns into

(define cond (lambda (p)
               (lambda (a)
                 (lambda (b)
                   (((release* p) a) b)))))

Note that it releases p at the point it is about to call whatever it wraps: since the only operation that ever happens in the language is function call that's the only place where promises ever need to be forced.

And now

(((cond p) a) b)

Turns into

(((cond
    (hold p))
  (hold a))
 (hold b))

So here you can see that all the arguments get held in function calls, which gives you normal-order semantics.

The rules which turn oa into Scheme, really, are just two, one for each construct in oa.

  • (λ x y) -> (lambda (x) y)
  • (x y) -> ((release* x) (hold y))

So it's clear you can mechanically turn λ-calculus into another language. It helps if that other language has things like macros and first-order functions, but if you were willing to work hard enough you could turn it into anything else. One nice thing about the approach above of turning oa into Scheme is that oa functions are just Scheme functions in fancy dress (or, really, Scheme functions are oa functions in fancy dress, or perhaps they have swapped each other's clothes or something).

So then we've reduced the question to a simpler one: how do you compile Scheme?

Well, first of all that's a big question to ask: far too big for a Stack Overflow answer. There are a lot of Scheme compilers out there, and although they probably have common features they're by no means the same. And, because a lot of them are fairly hairy, actually looking at the code they produce is often not very informative (the disassembly of (λ x x) in oa seems to be 154 lines).

There are, however, at least two particularly interesting references for compiling Scheme.

The first (or almost the first) Scheme compiler was called 'Rabbit', and was written by Guy Steele. I don't know if Rabbit itself is still available, but Steele's thesis on it is, and there's a texty version of it here which looks superficially more readable but has problems.

The Scheme dialect Rabbit compiled is a fairly distant ancestor of modern Scheme: I think enough of it is described in the thesis to understand how it worked.

Rabbit compiled to MACLISP not machine language. So now there's another problem: how do you compile MACLISP? But in fact it compiled to an extremely restricted subset of MACLISP for which it's reasonably easy to see how it could be turned into machine code, I think.

The second interesting reference is the Wizard book: Structure and Interpretation of Computer Programs. Chapter 5 of SICP is about register machines and compilation for them. It defines a simple register machine and the implementation of a compiler for Scheme (or a subset of Scheme) for it. Figure 5.17, on pages 597 & 597 contains the compiled output of an obvious recursive factorial function for the register machine defined in the book.

Finally: that chapter of SICP is 120 pages long: that's why this is too big a question for Stack Overflow.


As an addendum, I fixed what I think is an idiocy in oa/normal which makes the compiled output much more tractable. Using the disassemble package for Racket (with some glue to attach it to the oa/normal/pure language, and using Racket/CS):

> (disassemble (λ x x))
       0: 4883fd01                       (cmp rbp #x1)
       4: 7507                           (jnz (+ rip #x7)) ; => d
       6: 4c89c5                         (mov rbp r8)
       9: 41ff6500                       (jmp (mem64+ r13 #x0))
       d: e96ecc35f8                     (jmp (+ rip #x-7ca3392)) ; #<code doargerr> ; <=
      12: 0f1f8000000000                 (data)

I think it's even possible to work out what this is doing: the first two instructions are checking for a single argument and punting to an error handler if not. The third instruction is (I presume) moving the argument into wherever it needs to be when the function returns (which I guess is rbp, and the argument presumably comes in r8), and then it jumps to wherever it needs to be next. Compare this to

> (disassemble (λ x 1))
       0: 4883fd01                       (cmp rbp #x1)
       4: 750b                           (jnz (+ rip #xb)) ; => 11
       6: 48c7c508000000                 (mov rbp #x8)
       d: 41ff6500                       (jmp (mem64+ r13 #x0))
      11: e96acc0cc7                     (jmp (+ rip #x-38f33396)) ; #<code doargerr> ; <=
      16: 0f1f8000000000                 (data)

This is the same but the (mov rbp #x8) is moving the fixnum 1 into rbp I assume, which has been shifted in the usual way because I guess the system uses low tags.

Obviously things get rapidly more hairy from there.

Related