Session 28
Optimizing a Simple Interpreter
Where Are We?
You are implementing a small language for programming colors, Huey. On Homework 9 and Homework 10, you produce a set of tools for a language with colors, operators for manipulating them, and local variables. On Homework 11, you will add sequences of statements and mutable data.
Our next two sessions will step away from the Huey interpreter to consider other programming language issues you hear about in modern software development. Today, we consider optimization: techniques that interpreters sometimes perform in order to make programs run faster or use less space. As our sandbox for this discussion, we'll use an odd little language...
A Quick Puzzle: An Odd Little Language
+++++++. ,[.,] ++ > +++++ [ < + > - ] < .If those prove too easy, then try your hand at this one:
+++>+++++< [>>>+>+<<<<-]>>>>[<<<<+>>>>-]< [<< [>>>+>+<<<<-]>>>>[<<<<+>>>>-]< [<<+>>-] <-]
Unless you are a computational savant, getting through this last one will not be possible in the time we have for the exercise — unless perhaps you recognize some patterns and do batches of operations at a time. Can you get to the end of Line 2?
The BF Language
— any programmer, any time, anywhere
The language you just played with is BF. It is:
the ungodly creation of programmer Urban Müller, whose goal was to create a Turing-complete language for which he could write the smallest compiler ever
The name "BF" is an acronym of sorts for the language's actual name, which includes a word I won't use here, or anywhere else, to be honest. I'll call it BF throughout this discussion.
According to Wikipedia, Müller first wrote a compiler that took up only 296 bytes and then later lowered that to 240 bytes. Another enthusiast has written a BF compiler in 104 bytes of assembly language.
We programmers are an odd lot.
BF serves as a useful language for us today. Even though it is tedious to write BF programs, it is a fairly standard programming language, with pointers, dereferencing and loops. It's incredibly simple and can be interpreted by a simple program.
Indeed, BF is almost a theoretical model for how programs work. In your theory of computation course, you might learn that the lambda calculus and the Turing machine are two models for how computation works. BF resembles a Turing machine in the same way that our pure function solution for implementing pairs in Session 22 resembles the lambda calculus.
As simple as they are, BF programs are far less efficient than we might hope at run-time. As a result, the language offers us a small playground for exploring one of the ways that modern interpreters and compilers improve the performance of programs: optimization.
Before we look at interpreting BF, let's consider a few programs. First, the programs from your exercise:
- The first prints ASCII 7. It rings my bell! (Did you hear it?)
- The second is cat, a program that echoes stdin to stdout.
- The third computes and prints 2 plus 5. That's ASCII 7, which again rings the system bell. This version of 2 plus 5 prints an answer of 7. It has to convert the number 7 into the ASCII value for the digit 7 before printing.
Already things have gotten complex!
Let's step back to a simpler program, 1-through-5, which prints the first five digits in order:
++++++++ ++++++++ ++++++++ ++++++++ ++++++++ ++++++++ >+++++ [<+.>-]
and consider how it works:
- Line 1 initializes memory cell 0 to 48, which is the ASCII code for 0.
- Line 2 initializes memory cell 1 to 5, to use as a loop counter.
- Line 3 loops until cell 1 equals 0. On each pass, it increments cell 0, prints its value, and decrements cell 1.
The code gets long fast, but we can write amazing programs in BF:
- Hello, World! — the old standard
- 3 times 5 — the challenge above; multiplication is repeated addition...
- cell size — determines the size of an integer on the machine
- factorial — prints out successive factorials until you kill it
- rot13 — encodes stdin using the ROT-13 substitution cipher
Things to note:
- We have to convert digits to their ASCII code before printing.
- BF does not define EOF on input. Results are machine-specific.
-
All characters not in '
<>+-.,[]
' are ignored.
The last of these features enables a "literate programming" style, in which the programmer can intersperse BF code freely among text that describes the code. This version of 2 plus 5 has running commentary on the right!
Another cool thing to note: we can translate BF code directly into C using this set of formulas:
-
>
++p;
-
<
--p;
-
+
++*p;
-
-
--*p;
-
.
putchar(*p);
-
,
*p = getchar();
-
[
while (*p) {
-
]
}
... assuming, of course, that we create a main function to contain
the code, define p
as a char*
, and
create the memory array first.
(Go ahead, try it: Write a program that takes in a BF program and produces the equivalent C!)
A BF Interpreter
To understand the language better and have a starting point for improving performance, consider this simple BF interpreter written in Python.
We don't have Racket reading Racket-friendly input here, but we still have a simple parser. BF's concrete syntax is sparse: only eight characters, ignoring all others!
In the interpreter itself, six of the operators can be implemented as one-liners.
The more challenging cases are the branching operators,
[
and ]
, which pair up. When the
interpreter figures out that it needs to jump, it has to find the
matching bracket, forward or backward. Because brackets can be
nested, finding the matching bracket requires a bit of bookkeeping.
If this seems to you to be a wasteful thing to do at run-time, you
are right. It implements an O(n²) operation every time the
interpreter tries to jump to the top or bottom of a loop!
We have a something to optimize
Things to note:
- Python creates a new problem for single-character input from the user. See this StackOverflow page for more detail, if you care.
Optimizing the Interpreter
"Optimize" is a misnomer... We aren't shooting for a perfect program, only one that is better in some way we care about.
Optimization can be done at any point in the process of processing a program. In our case, we will optimize the parsed program before we pass it to the interpreter.
As we saw above, there is one obvious way to improve my simple
interpreter: avoid looking for the matching bracket every time it
sees a [
or a ]
. The
potential speed-up in a serious program, such as
a program to factor an integer,
is quite large. If there is a "hot" inner loop (one that runs
many, many times during one execution of the program, even
billions), the simple interpreter will scan the source to find
the matching bracket every single time.
An alternative is to precompute the jump destinations before execution. This is safe, because the BF program does not change while it is running.
We can implement this idea in a preprocessor. This interpreter creates a jumptable and passes it to the interpreter.
The function that creates the jumptable does the same thing that
the simple interpreter does. But the simple interpreter does it
at run-time, every time it sees a bracket, while this function
does it once, before interpreting, and records what it
finds. The result is an array named jumptable
that is the same length as the program. jumptable[i]
holds the index of the matching bracket. For any non-bracket
character in the program, jumptable[i]
is 0. The
while
loop stops to work only on [
s,
because it can record the corresponding index for the matching
]
s at the same time.
The process is still O(n²), but now the interpreter runs the process only once, before the BF program is executed.
Demo compute_jumptable
on these expressions:
[+++++]
++>+++++[<+>-]<.
-
++>[+++++[<+>-]<].
— nested loops
Now the interpreter can handle all eight operators quickly: six one-liners and two two-liners. (The two-liners could be one-liners...) Even better, they read just like the descriptions of the operators in the language handout you read. Even more better, the code in our evaluator itself is much simpler now!
The speed-up from this step is noticeable. On a program to factor large numbers, the run-time is now less than half the run-time of the simple approach.
There is so much more room to optimize:
- runs of move instructions and increments/decrements
- higher-order patterns: copy, inc-and-dec
Runs of moves, increments, and decrements are inefficient. For each character in the program, our interpreter must:
-
advance the
pc
and compare it to the length of the program, - retrieve the instruction at the
pc
, - find the right case to execute based on the value of the instruction, and
- execute the instruction.
An idea for optimizing this process: parse the program to abstract syntax (!). In our abstract syntax, we could increment (or decrement) the pointer (or the value in a memory slot) by n, rather than 1. This would collapse a run of operators into a single super-operator in the abstract syntax.
There are more patterns to find in source programs and thus more abstract syntax to create: loops that zero a position, loops that move a value, etc.
Implementing these pattern-finding ideas would allow the interpreter to execute even more efficient code. They can give another 40% speed-up.
Comparing Results
In order to compare the execution times of the basic and optimized
interpreters, I created versions of both that support a verbose
mode:
simple
and
optimized.
To run them in verbose mode, pass a -v
flag after
the BF program name when you run the interpreter.
Esoteric Languages
BF is not unique. There is a family of so-called esoteric languages (also known as esolangs) that programmers have created to test the boundaries of programming language design — or simply to have fun.
BF was not the first esoteric language created, but it is the best-known and the launching point for many others. One of my favorite descendants of BF is Ook. I once wrote an Ook interpeter on a flight home from OOPSLA in California. Here is "Hello, world" in Ook. (You can run the Ook program here.)
One of the more artistic esolangs is Piet, which is named for the Dutch abstract painter Piet Mondrian. He created paintings that look like this:
That is a legal program in the Piet language! It prints 'Piet'. Here is another legal Piet program:
It prints "Hello, World". Here's another:
This program is prime?
: it reads a number from
standard input, determines whether it is prime or not, and prints
'Y' or 'N'. And it does so with a smile! Amazing.
How about this one?
Do you notice anything special about the image? It is made up entirely of legal Tetris pieces. The program prints... "Tetris". Programming truly is an art!
If you don't believe my claims that these are legal Piet programs, try this online Piet interpreter. It uses a simple Javascript user interface in front of a Piet interpreter, the code for which we can download, compile, and run on your computer.
What can we learn from all this?
- Languages can be fun.
- Simple languages can help us see ideas more clearly.
- Some programmers are crazy.
BF in Racket
Here's one last example of how programmers can have fun with esoteric languages, one that connects back to our studies this semester. In Session 21, we learned how to create new syntax in Racket and that we could even create a whole new language within Racket. Programmer Danny Yoo implemented a Racket language for BF so that we can run BF programs in Dr. Racket!
After we install Yoo's package, we can put a new #lang
line at the top of any BF program, load it in to Dr. Racket, and
hit 'Run'. Here are
Hello, World!
and
factorial.
Programmers really are a funny lot. Mostly, we just like to program.
References
Language alert: all the pages linked here use BF's full name.
I adapted the description in our opening handout from Brian Raiter's introduction to BF.
The inspiration for this session came from a short blog series by Eli Bendersky on how to write just-in-time compilers. Part 1 of the series discusses BF interpreters and optimization. He covers JIT compilation in Part 2; We don't get to see much of that in this session.
The BF programs I give you come from Raiter, Bendersky, Wikipedia, and Daniel Cristofani.
To run BF programs in Dr. Racket, you have to install the
dyoo/bf
package. When you open your first BF program
in Dr. Racket, hit 'Run' to install and run the code. Thereafter,
you'll be able to run BF programs immediately. You can even run
them at the command-line:
$ racket hello-world.rkt | more Hello, World!
Wrap Up
-
Reading
- Study the notes for this session.
- If you want to read more but aren't ready to dive into Bendersky's more detailed article, check out this optional reading on just-in-time compilation.
-
Homework
- Homework 10 due today.
- Homework 11 is available and due in one week.