For years I used compfuck, my little Brainfuck-to-Carp compiler, to explain the shape of a Futamura projection, even though I knew it wasn’t quite accurate and not quite how Futamura projections worked. I felt that I captured the general feeling, however, and that was interesting in itself.
At Lambda
Lounge, I wrote an interpreter, moved its work into compile time,
and showed how we’d arrived at a compiler. I think that evokes much the
same emotion as the first Futamura projection, because once we know the
program, we can do some of the interpreter’s work ahead of time. But
compfuck explicitly chooses which code to emit for each
instruction. A partial evaluator derives that code by specializing an
interpreter. It’s more general, and a bit more profound, even though the
resulting compiler is maybe not quite as powerful.
This time, we’ll go backwards first. We’ll turn compfuck back into an
interpreter, then figure out which parts we can evaluate with only the
source program available. If you’re wondering why we’d do that when we
already have a working compiler, that’s fair! The useful case is when we
have an interpreter and no compiler yet, or when the manual solution we
reached for before is not as easily available as in Brainfuck.
compfuck gives us something small and familiar on which to
work through the idea, even if we already have a perfectly valid
execution environment..
I’ll assume a little familiarity with Carp macros and Brainfuck. My original post introduces both, and even leaves turning the compiler into an interpreter as an exercise. Let’s finally do that.
Going backwards
The old compiler’s instruction dispatch contains branches like this:
\> (list 1 '(set! h (+ h 1)))When we encounter >, we return how far to advance
through the source and a piece of code that will move the tape head
later. Inside that pair is a quoted operation. In the interpreter, we
perform it instead:
\> (set! h (+ h 1))The other simple instructions likewise become ordinary operations. The recursive walk through the source is basically the same idea, although it now executes instructions instead of collecting code fragments.
The question is whether we can recover those fragments by evaluating the parts of this interpreter that only depend on the source. What does that mean?
Well, given ,[.-], we already know that the first
instruction reads input, and that the loop body prints and decrements a
cell. We don’t know how often that loop will run.
The result we’re after looks like this (without the surrounding ceremony):
(do
(Array.aset! t h (IO.Raw.get-char))
(while (/= &0 (Array.unsafe-nth t h))
(do
(IO.print &(str (Char.from-int (copy (Array.unsafe-nth t h)))))
(Array.aupdate! t h &(fn [x] (max 0 (Int.dec x)))))))The source traversal has disappeared, while the operations on the tape and the loop remain. This is called the residual program in Futamura speak, the code left over after our partial evaluator completes. Our old compiler assembled something much like it. This time we want to infer it from the interpreter’s source.
That is the first Futamura projection, which we can write down as simply:
compiled = specialize(interpreter, program)
compiled(input) = interpreter(program, input)
It takes an interpreter and a program, and it returns another program with all the statically-knowable bits evaluated.
Now let’s make that work.
Keeping the interpreter around
We need an ordinary function that we can call, but we also need its source for specialization. A small definition macro will give us both:
(defmacro definterpreter [name args body]
`(do
(defn %name %args %body)
(meta-set! %name "source" %body)))This expands to a normal defn and stores the original
body as metadata on the function. The backtick constructs code, and
% inserts a value into it. We’ll retrieve the body using
(meta interpret-compf "source") later. Keeping it around
means we get the forms we wrote, before Carp expands and type-checks
them.
Our bracket search is needed at both runtime and compile time, so we’ll give it a similar wrapper:
(defmacro defstatic [name args body]
`(do
(defn %name %args %body)
(defmodule Dynamic (defndynamic %name %args %body))))
(defstatic search-matching-bracket [s i m]
(if (or (= i (String.length s)) (= m 0))
i
(let [f (String.char-at s i)]
(search-matching-bracket s (inc i)
(if (= f \]) (dec m) (if (= f \[) (inc m) m))))))defstatic defines a compiled function and a compile-time
function from the same body. The scan still counts nesting depth, as in
the old compiler. I’ve changed it to advance an index rather than
repeatedly take the tail of the string, because I’ve gotten older and
more careful. Starting just after [, with depth one, it
returns the position just after the matching ].
Both of these macros are basically general-purpose plumbing, but they’re cool and I wanted you to see them.
With that in place, here’s the interpreter:
(definterpreter interpret-compf [p i t h]
(if (= i (String.length p))
h
(let [f (String.char-at p i)]
(if (= f \])
h
(let [incr (if (= f \[) (- (search-matching-bracket p (inc i) 1) i) 1)]
(do
(case f
\+ (Array.aupdate! t h &(fn [x] (mod (Int.inc x) 255)))
\- (Array.aupdate! t h &(fn [x] (max 0 (Int.dec x))))
\> (set! h (+ h 1))
\< (set! h (- h 1))
\. (IO.print &(str (Char.from-int (copy (Array.unsafe-nth t h)))))
\, (Array.aset! t h (IO.Raw.get-char))
\[ (while (/= &0 (Array.unsafe-nth t h))
(set! h (interpret-compf p (inc i) t h)))
())
(interpret-compf p (+ i incr) t h)))))))p and i are the program and our position in
it. t is a reference to the tape, and h is the
head’s position. Reaching the end of the string or a closing bracket
returns the head. Otherwise we execute an instruction and recurse.
Characters outside the eight Brainfuck instructions do nothing, and as
is customary in brainfuck we can use them for comments.
For [, we repeatedly interpret the body starting at the
next character. Each call returns when it reaches ]. Since
the body can move the head, we keep its returned position for the next
iteration. Afterwards, incr takes us past the whole loop.
All other instructions advance by one character.
The knowable
This is the fun bit: we build an interpreter for the parts of Carp
that we need to understand interpret-compf.
Let’s divide the interpreter’s work. The program p and
position i are known during specialization. So are the
instruction f, the bracket scan, and the amount
incr by which we advance. In partial evaluation
terminology, these are static.
We’ll leave the tape and head entirely dynamic, meaning they
belong to the residual program1. Even though
their initial values are known, we won’t follow them during
specialization. That also means we won’t execute the while
even if it’s fully knowable. Left as an exercise to the reader.
This division is particularly convenient for our interpreter. Every
if and case test, and every single-binding
let, belongs to the source traversal. We can evaluate
those. The runtime operations don’t refer to any of those static local
variables. We’re making these decisions by hand, which keeps our
specializer small and limits it to this interpreter’s structure.
Here’s the whole thing:
(defndynamic specialize-compf [body expression bindings]
(let [known (fn [form] (eval (list 'let bindings form)))
recur (fn [form] (specialize-compf body form bindings))]
(if (or (not (list? expression)) (empty? expression))
expression
(case (car expression)
'if (recur (if (known (cadr expression)) (caddr expression) (cadddr expression)))
'let (specialize-compf body (caddr expression)
(append bindings (array (car (cadr expression)) (known (cadr (cadr expression))))))
'case (recur (case-internal (known (cadr expression)) (cddr expression)))
'interpret-compf
(let [saved (gensym)]
`(let [%saved %(last expression) h %saved]
%(specialize-compf body body
(array 'p (known (cadr expression)) 'i (known (caddr expression))))))
(map recur expression)))))There are a few nested list accesses here, so let’s walk through
them. body is the complete interpreter body,
expression is the part we’re currently looking at, and
bindings holds the static names and values.
known evaluates an expression inside a let
containing those bindings, recur continues specialization
with the same bindings.
For an if, we evaluate its test and continue with the
selected branch. For a let, we evaluate its binding, add it
to our environment, and continue with its body.
case-internal, a helper from Carp’s standard library, turns
a case into nested if expressions, which we
already handle.
A recursive call needs a little more care. Its program and next
source position are static, so we can unfold another copy of the
interpreter body with those values. The tape reference is passed through
unchanged. The head is a runtime argument, though, and we save it under
a fresh name and bind a local h for the unfolded call. That
preserves the function’s local cursor and returned value.
Everything else is rebuilt by recursively visiting its parts. This
includes while. We specialize its body, but retain the loop
and its condition. We never need to determine the number of
iterations.
For ,[.-], the recursive call inside the loop starts at
the .. We retain the output operation, then the decrement,
then reach ] and return h. That finishes
specializing the loop body. The interpreter’s while is
still there to repeat it at runtime. This is how we get the code from
Figure 3, along with the cursor bindings omitted there.
Putting it back together
We can now pull ot the interpreter body and specialize it with a known program and starting position:
(defndynamic compile-compf [p i]
(let [body (meta interpret-compf "source")]
(specialize-compf body body (array 'p p 'i i))))
(defmacro compf [prog]
`(defn main []
(let [tape (Array.replicate 30000 &0)
t &tape
h 0]
(ignore %(compile-compf prog 0)))))The public API remains (compf "some Brainfuck"). The
macro inserts the initialization as before, and splices in the
specialized body. We ignore its returned cursor because
main has nothing left to do with it. Carp compiles the
result in the usual way.
And it works!
鲤 (load "compfuck.carp")
鲤 (expand '(compf "+[.+]"))
=> ; "pretty" printed for you (no amount helps)
(defn main []
(let [tape (Array.replicate 30000 (ref 0)) t (ref tape) h 0]
(let [_ (do
(Array.aupdate! t h (ref (fn [x] (mod (Int.inc x) 255))))
(let [gensym-generated1042 h h gensym-generated1042]
(do
(while (/= (ref 0) (Array.unsafe-nth t h))
(set! h
(let [gensym-generated1043 h h gensym-generated1043]
(do
(IO.print (ref (str (Char.from-int (copy (Array.unsafe-nth t h))))))
(let [gensym-generated1044 h h gensym-generated1044]
(do
(Array.aupdate! t h (ref (fn [x] (mod (Int.inc x) 255))))
(let [gensym-generated1045 h h gensym-generated1045] h)))))))
(let [gensym-generated1046 h h gensym-generated1046]
h))))]
())))
Easy, right?
Fin
I think the similarity that made compfuck useful for explaining the projection is still there. We know the source, do the work that we can, and leave a program for runtime. But now we have a general mechanism to fold an interpreter into our pipeline.
As is often the case, the more general idea is considerably more intricate than this little implementation. We supplied the division between static and dynamic work ourselves, and relied on the interpreter’s particular structure. A partial evaluator that can make these decisions for a larger language has more work to do. But, given enough power, an interpreter becomes a way to obtain compiled programs without separately writing their instruction translations, which is bonkers to me.
Today, we folded a compiler into an interpreter, and then took the scenic route back. Not a bad use of an afternoon.
See you soon!
Footnotes
1. Unfortunately, this is a different use of
“dynamic” from Carp’s defndynamic, whose functions run at
compile time. Here it means that we leave the computation until the
generated program runs. We have two different static/dynamic divides in
the same post, and I apologize.