Session 28
Optimizing a Simple Interpreter
Where Are We?
You are implementing Boom, a small language for programming with numbers. On Homework 9 and Homework 10, you produce a set of tools for a language with number values, 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 Boom 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
Then trace the three programs at the bottom of the page.
+++++++. ,[.,] ++ > +++++ [ < + > - ] < .
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, what do the programs from your exercise compute?
- 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!
Here is 1-through-5, which prints the first five digits in order:
++++++++ ++++++++ ++++++++ ++++++++ ++++++++ ++++++++ >+++++ [<+.>-]
This program embodies several key ideas. How does it work?
- 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.
BF code gets long fast, but we can write amazing programs in it:
- Hello, World! — the classic first program in every programming language
- 3 times 5 — the challenge problem 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
Some things to note about BF programs:
- We have to convert digits to their ASCII code before printing.
- BF does not define EOF on input. Results are machine-specific.
-
Any character other than
<>+-.,[]
is 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, so we 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!
Some things to note about the interpreter:
- 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), 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.
A Preprocessor for BF
We can implement this idea in a preprocessor. This interpreter creates a jump table and passes it to the interpreter.
The idea is quite simple. If character i in the
program string contains a '[' or a ']', then
jumptable[i]
holds the index of the matching
bracket. For any non-bracket character in the program,
jumptable[i]
is 0.
The function compute_jumptable
builds the jump
table. It does the same thing that the original interpreter
does, walking down the string, keeping track of nested brackets,
until it finds a match. But the original interpreter does this
at run-time, every time it sees a bracket. The
optimizing interpreter calls this function once, before
interpreting. It records what it finds in an array named
jumptable
that is the same length as the program.
Note: When building the jump table, the while
loop
only stops to work 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.
For example:
>>> compute_jumptable('[+++++]') [6, 0, 0, 0, 0, 0, 0] >>> compute_jumptable('++>[+++++[<+>>-]<].') [0, 0, 0, 16, 0, 0, 0, 0, 0, 14, 0, 0, 0, 0, 9, 0, 3, 0]
With the jump table in hand, the interpreter can now 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 specification you read. Even more better, the code runs much faster!
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.
More to Optimize
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. At run time, for each character in the program, the 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.
One way to optimize this process is to parse the program to abstract syntax.
-
Where the concrete syntax has a
+
operator to increment a slot by 1 and a-
operator to decrement a slot by 1, the abstract syntax could have anupdate(n)
operator that adds a positive or negative n to the value in a memory slot. -
Likewise, it could have a
move(n)
operator to increment or decrement the pointer by ±n rather than>
and<
working one slot at a time.
The two new operators would collapse a run of operators in the source code into a single super-operator in the parsed program, allowing the interpreter to treat runs of moves, increments, and decrements with a single pass through the processing loop.
The parser could take advantage of more complex patterns in source programs: loops that zero a position, loops that move a value to another slot, and so on. If we create abstract syntax to fold these patterns into single operators, the interpreter will be even more efficient. Such operators deliver another ~40% speed-up.
Esoteric Languages
BF is not unique. There is a family of so-called esoteric languages (also known as esolangs) created by programmers 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 interpreter 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.
- [optional] — If you want to read more but aren't ready to dive into Bendersky's more detailed article, check out this reading on just-in-time compilation.
-
Homework
- Homework 10 is due tomorrow.
- Homework 11 will be available soon and due in one week.