Writing a Language in Truffle. Part 1: A Simple, Slow Interpreter
How hard is it to write a simple, fast interpreter? Let's find out.
It’s fun writing little language interpreters in Python. You can get a fully functional interpreter in about an hour but of course my toy interpreters are just that: a toy. Writing a lisp interpreter on top of an already slow language like Python will not win any speed competitions. You may get away with writing small Domain Specific Languages (DSLs) as an interpreter, but you can forget about any general programming language. The performance hit makes it untenable unless you write it in some lower level language like C; who wants to do that? If you want to target higher level virtual machines like the JVM you’re left with writing a compiler that takes your code and produces JVM bytecode. How about writing a compiler that targets Javascript? Another not-so-fun alternative.
Thankfully, a new solution is here. You can write your interpreter in a VM that is designed to optimize your interpreter with all that wonderful JIT compilation magic. Oracle labs has released its own VM that hopes to make writing language interpreters both easy and fast. It can also leverage the huge ecosystem of the Java Virtual Machine (JVM). This modified JVM contains a new Just-In-Time (JIT) compiler that can speed up interpreters like my little lisp to near-Java speeds. The new JIT compiler is called Graal. To take advantage of Graal’s JIT-y goodness you use the Truffle library to annotate your interpreter and give Graal some hints on invariants and type information. For this integration effort you get significant speedups in your interpreter without having to resort to writing a bytecode compiler plus you have the full power of Java at your disposal.
Graal isn’t the only game in town. PyPy has been around for a few years and has grown into such an environment. It originally started as a new Python interpreter written in a smaller, more restricted version of Python appropriately named RPython (R = restricted). You don’t get all the syntactic goodness of regular Python but, it sure beats writing your interpreter in C. The developers have made herculean efforts to create a VM that can take a high level language like Python and make it fast even when written in a still high level language like RPython. It’s so good it’s faster than the standard CPython implementation in most benchmarks. It’s such a promising platform there’s an implementation of Ruby called Topaz that is already performing faster in some benchmarks than the much more established implementations like JRuby. Exciting stuff, but now PyPy isn’t the only game in town. I’m going to focus on Truffle.
The initial results of Truffle are very exciting. Implementations of Ruby and Javascript in Truffle have performances on the same order of magnitude as the much bigger projects of JRuby and Nashorn, respectively. They are even comparable in speed to more established projects like the Google’s V8 Javascript interpreter. The kick: these Truffle implementations were done with fewer people in a shorter period of time. This means you can create your own language on the JVM that takes advantage of all it’s existing libraries, native threading, JIT compiler without without having to write your own fullblown optimizing compiler, and you get speeds that took other languages man-years (man-decades?) to achieve. Where do I sign up?
To see if the claims are true I’m going to write another simple Lisp language which I’ll call Mumbler and see how easy it is. I’ll write a simple, non-Truffle interpreter and judge how easy it is to translate to Truffle. I’ll take benchmarks to see what kind of speed gains we get by switching over to Truffle. If everything goes well, perhaps this proof of concept will reach speeds comparable to other production-quality lisps like Clojure, Racket or CHICKEN (scheme), but I’m getting ahead of myself.
Mumbler Language
I’m going to try to keep a balance between simplicity and useful features. I don’t want a language that’s so simple that it’s a strawman, but I don’t want to spend too much time writing a language I intend to throw away. Mumbler will be a simple but fullblown language. I’ll stick to basic datatypes and little-to-no syntactic sugar.
For those unfamiliar with lisps, it operates much like other dynamic languages. The biggest difference to most people is all the parentheses in the syntax, but they are there for a reason. Lisp programs are written in its own datatypes. This means that every element you see in Mumbler (and all lisps) is the same datatype you use within your own program. This uniformity gives lisps a lot of power which Mumbler sadly doesn’t tap for the sake of brevity. The language will have a syntax as simple as possible and only a couple of datatypes. Lisps can go very far with what would normally be considered an anemic set of types and operations.
Datatypes
-
Numbers (e.g.,
1
,400
)Mumbler will stick with positive integer literals. You can use the
-
function to create negative numbers if need be. This will be implemented by boxed Long. It’s slow, but we’re shooting for a barebones language. We also won’t deal with integer overflows. -
Boolean (i.e.,
#t
,#f
)Nothing special here. The tokens
#t
and#f
will evaluate to the Java static final valuesBoolean.TRUE
andBoolean.FALSE
, respectively. -
Symbol (e.g.,
var
,some-variable
)Symbols are kind of unique to lisps. If you’re not familiar with them, they’re string-like but are typically used for keys. Think of symbols in Ruby if you’re familiar with Ruby but without the
:
in the front. Symbols show their uniqueness when they’re evaluated. -
Function (e.g.,
(lambda (x) (+ x 1))
)Functions are written with the
lambda
special form. Functions are first class values that can be stored in a variable, passed to another function or returned by a function. -
List (e.g.,
(1 2 3)
,(a list of symbols)
)Lists are the only way to make compound structures in Mumbler. It’s a little limiting but it’s plenty for our little language.
Lists are implemented as singly-linked lists. There are pros and cons with this implementation, but it’s the traditional implementation in lisps. We’re not blazing any trails here with language design so we’ll stick with the tried and true implementation.
The empty list
()
acts as Mumblersnull
value.
Syntax
The syntax is simple.
- Tokens are separated by whitespace or opening/closing parentheses.
- Numbers are tokens that are digits. Only numbers can begin with a digit.
- Booleans are the tokens
#t
#f
. Any other token that begins with#
is illegal. - An open paren
(
starts a new list. A)
is closes the list. Lists can be nested. - Everything else is a symbol.
Here is some syntactically correct Mumbler code:
12344
-123
the-above-is-a-symbol-because-Mumbler-does-not-have-negative-numbers
#t
(a list of symbols)
Evaluation
To keep with our theme of simple, evaluation is as basic as we can make it. The program is evaluated from top to bottom. That means that a variable definition must appear before its use. Everything is an expression. Since Mumbler programs are made of Mumbler datatypes every datatype has a well-defined evaluation strategy.
-
Number (e.g.,
1
,4929
)Numbers evaluate to themselves. That is, a number returns a number. Shocking, I know.
-
Boolean (e.g.,
#f
)Booleans evaluate to themselves. Same as above.
-
Symbol (e.g,
a-variable
,a-variable-that-contains-a-function
)Symbols evaluate by returning looking the current namespace and return the value with the same name as the symbol. When you say
some-variable
the interpreter will see a symbol and try to evaluate it by looking in the current namespace for something stored under"some-variable"
. -
List (e.g.,
(+ 1 2 3)
)Lists are evaluated as function calls. Every element in the list is evaluated before the function is applied to its arguments. The first element must evaluate to a function. The rest of the elements are the arguments. The arguments are mapped to the formal parameter names before the body of the function is called. The last expression in the function body is the return value.
Scoping
Mumbler has lexical scope. If you’ve used any modern dynamic languages like Javascript, Python or Ruby then you know how this works. Basically, it means that functions defined within a wrapping function has access to the outer function’s variables—even if the inner function is the return value and is not called until some time in the future.
Mumbler doesn’t have blocks (the list of statements within {}
in languages like Java, C, C++). New scopes are only created within new functions.
Special Forms
The evaluation process for function calls is defined to evaluate all the arguments, but we don’t want this in certain circumstances. In these cases we’ve defined unique evaluation schemes called special forms.
-
define
This creates a new variable within the current scope.
define
has to be a special form because the variable name doesn’t exist yet (which is why we’re callingdefine
) and would throw an error for an undefined variable.Example:
(define c 299792458)
-
lambda
This creates an anonymous function. The second element (the first being the symbol
lambda
) is the list of formal parameters. Lambda will be used withdefine
to store a function with a name.lambda
has to be a special form because we don’t want to evaluate the body immediately; only when the function is the first element in a function call. Also, the arguments need to be mapped to the parameter names before the body can be evaluated or else we would get undefined variable errors.The name
lambda
is used for historical purposes. We’ll just say that it’s the traditional name for anonymous functions in lisps and leave it at that.Example:
(lambda (x) (* x x))
-
if
The only control flow structure in Truffle. This works as you’d expect, the second element (the test expression) is evaluated, and if it’s true the 3rd element (the “then” expression) is evaluated. If it’s false the 4th element is evaluated.
if
has to be a special form because only the then or the else expression must be evaluated. Ifif
was a normal function both clauses would be evaluated, the else expression would always return, and that would be bad.Remember,
if
is an expression like everything else. It returns the result of evaluation of either the then or else expression. Lisp ifs are more like the C-style ternary operator:test() ? then_result() : else_result()
.Example:
(if (= x 0) (+ x 1) (- x 3))
-
quote
This returns the argument without evaluating it. Since all instances of Symbol and List types in Mumbler evaluate to variable lookup and function calls, respectively, we need a way to get a hold of actual Symbol and List objects.
quote
allows us to do that. With lists you can use thelist
builtin function to get a list object, but symbols needquote
because there’s no other way to get a Symbol object.Keep in mind, quote doesn’t evaluate anything in its argument. That means if you pass a list it’s elements are not evaluated. A symbol within a list will stay a symbol. You’re better off using the
list
function unless you really want a list of symbols.Because numbers and booleans evaluate to themselves, quoting them doesn’t do anything.
Example:
(quote (a list of undefined symbols))
With these few special forms we have a fully functional, Turing-complete programming language. We’ll have to be creative in combining them to get the functionality we need, but that’s part of the power of lisp.
Builtin Functions
Although we have a Turing-complete language it isn’t very useful if we can’t do anything like interact with the outside world or manipulate our datatypes. Here are the builtin functions that come with Mumbler. There’s nothing special about these functions other than they already exist when Mumbler starts. They are evaluated and called like user-defined functions.
-
Arimetic functions (
+
,-
,*
,/
,%
,=
,>
,<
)We have your basic arithmetic operations. Remember, Mumbler only has integers so
/
does integer division and%
is the modulo function. -
List functions (
list
,cons
,car
,cdr
)Our basic functions for creating, prepending and splicing lists. The names
cons
,car
,cdr
probably don’t make sense if you’re unfamiliar with lisp but they basically meanprepend
,first
,rest
respectively. Again, we’re sticking with lisp tradition here. For more background, you can read the Wikpedia article on cons. -
IO functions (
println
,now
)Since we’re not planning to do any real work in Mumbler, we’ll just implement a function to send data to standard out and get a current timestamp.
I may have to add more functions in the future but this is a good start.
SimpleMumbler
Now with a clear idea of what language we’re creating, let’s get started! We’ll start with an interpreter that doesn’t use Truffle as a base comparison. This version will be written the way I would normally write toy langauges, à la Peter Norvig’s lispy.
Design
The architecture will be a simple pipline from program text to evaluated result.
Read program text and return tree of expressions -> evaluate tree
The Reader
class will encapsulate converting from program text and return the tree of Mumbler expressions. The abstract syntax tree’s (AST’s) top node will be called with a default environment that includes all builtin functions. When interpreting a file the result of the top evaluation will be dropped.
Our main class will look like:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public class SimpleMumblerMain { public static void main(String[] args) throws IOException { assert args.length == 1 : "Mumbler file required"; runMumbler(args[0]); } private static void runMumbler(String filename) throws IOException { Environment topEnv = Environment.getBaseEnvironment(); MumblerListNode<Node> nodes = Reader.read(new FileInputStream(filename)); for (Node node : nodes) { node.eval(topEnv); } } } |
REPL
We can make a small addition at essentially no cost. We can take the basic flow described above and wrap it in a while
loop and create a REPL. If you’ve ever used languages like Python, Ruby or the Javascript console in web browsers you know a REPL. The REPL takes the text passed to it by the user and follows the flow above but then prints out the result instead of dropping it. We wrap it all in a while
loop so we always return back to the prompt.
Our updated main class is:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
public class SimpleMumblerMain { public static void main(String[] args) throws IOException { assert args.length < 2 : "SimpleMumbler only accepts 1 or 0 files"; if (args.length == 0) { startREPL(); } else { runMumbler(args[0]); } } private static void startREPL() throws IOException { Environment topEnv = Environment.getBaseEnvironment(); Console console = System.console(); while (true) { // READ String data = console.readLine("~> "); if (data == null) { // EOF sent break; } MumblerListNode<Node> nodes = Reader.read( new ByteArrayInputStream(data.getBytes())); // EVAL Object result = ListNode.EMPTY; for (Node node : nodes) { result = node.eval(topEnv); } // PRINT if (result != MumblerListNode.EMPTY) { System.out.println(result); } } } private static void runMumbler(String filename) throws IOException { Environment topEnv = Environment.getBaseEnvironment(); MumblerListNode<Node> nodes = Reader.read(new FileInputStream(filename)); for (Node node : nodes) { node.eval(topEnv); } } } |
We’re being a little quick and dirty with IOExceptions but the happy path will work fine. Now time to delve into the converting an InputStream
into a tree of Mumbler expressions.
Reading
Typically, languages break this up into two stages: lexing (text -> tokens), parsing (tokens -> syntax tree). I’m going to take another page from lisp history and combine lexing and paring into one step: reading. Because we were careful in defining the syntax of Mumbler, we can easily hand-write a reader that only needs one character of look ahead to determine what token is being read.
The basic flow of the reader is to read one character then dispatch to specialized read methods depending on the character read. Our readNode
function:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public static Node readNode(PushbackReader pstream) throws IOException { char c = (char) pstream.read(); pstream.unread(c); if (c == '(') { return readList(pstream); } else if (Character.isDigit(c)) { return readNumber(pstream); } else if (c == '#') { return readBoolean(pstream); } else if (c == ')') { throw new IllegalArgumentException("Unmatched close paren"); } else { return readSymbol(pstream); } } |
If you look closely at our implementation of SimpleMumblerMain you’ll see that we call Reader.read
and it returns a MumblerListNode. Our read
method can read multiple nodes at once and return them all. The read
method:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
public static MumblerListNode read(InputStream istream) throws IOException { return read(new PushbackReader(new InputStreamReader(istream))); } private static MumblerListNode read(PushbackReader pstream) throws IOException { List<Node> nodes = new ArrayList<Node>(); readWhitespace(pstream); char c = (char) pstream.read(); while ((byte) c != -1) { pstream.unread(c); nodes.add(readNode(pstream)); readWhitespace(pstream); c = (char) pstream.read(); } return MumblerListNode.list(nodes); } |
Within Reader
we only deal with PushbackReader
objects. The public method read(InputStream istream)
just converts the InputStream
to a PushbackReader
.
As you can see the read
method is straightforward. It accumulates all Node values into a list while the end-of-file hasn’t been reached then returns a MumblerListNode when it’s done. It’s also responsible for reading and throwing away whitespace so readNode
doesn’t have to worry about it.
I’m going to skip passed showing all the different read methods. They’re what you expect: read until you find a separator then convert the string to it’s respective type (i.e., number, boolean or symbol). I will show how lists are read because of some interesting twists. Lists are recursive and can read other nodes. We also check for special forms.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
private static Node readList(PushbackReader pstream) throws IOException { char paren = (char) pstream.read(); assert paren == '(' : "Reading a list must start with '('"; List<Node> list = new ArrayList<Node>(); do { readWhitespace(pstream); char c = (char) pstream.read(); if (c == ')') { // end of list break; } else if ((byte) c == -1) { throw new EOFException("EOF reached before closing of list"); } else { pstream.unread(c); list.add(readNode(pstream)); } } while (true); return SpecialForm.check(MumblerListNode.list(list)); } |
The two interesting bits of this method are (1) we recursively call readNode
to handle reading all elements in the list. Because start and end parentheses are separators we know that readList
will get back control once they are reached; (2) We call SpecialForm.check
to see if the list is not a function but actually one of our four special forms. The code for SpecialForm.check
is straightforward.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
private static final SymbolNode DEFINE = new SymbolNode("define"); private static final SymbolNode LAMBDA = new SymbolNode("lambda"); private static final SymbolNode IF = new SymbolNode("if"); private static final SymbolNode QUOTE = new SymbolNode("quote"); public static Node check(MumblerListNode l) { if (l == MumblerListNode.EMPTY) { return l; } else if (l.car.equals(DEFINE)) { return new DefineSpecialForm(l); } else if (l.car.equals(LAMBDA)) { return new LambdaSpecialForm(l); } else if (l.car.equals(IF)) { return new IfSpecialForm(l); } else if (l.car.equals(QUOTE)) { return new QuoteSpecialForm(l); } return l; } |
There’s nothing special about checking for special forms. I just want to make sure they aren’t evaluated as function calls. We’ll go into more detail about special forms later. The reader checks for special forms so the list type doesn’t have to constantly check if the first element of the is a special form when evaluating. This will speed up evaluation of function calls.
Eval
All the nodes the reader returns extend from the abstract class Node
. All Node
does is define an abstract method eval
. eval
takes an Environment
argumnet that contains the namespace for all the defined variables. For most datatypes (Number, Boolean, Function) eval
returns the same object. Actually, Number and Boolean return the boxed values of java.lang.Long
and java.lang.Boolean
, respectively, because Java doesn’t allow us to subclass java.lang.Long
and java.lang.Boolean
. If we could we would just return this
in those cases. The difference is subtle and doesn’t really matter. For the Function
type, we do return this
since we define a custom type.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
public abstract class Node { public abstract Object eval(Environment env); } public class Number extends Node { private final Long num; public Number(Long num) { this.num = num; } @Override public Object eval(Environment env) { return this.num; } } public class Function extends Node { // other code... @Override public Object eval(Environment env) { return this; } } |
Symbol
is a little more complicated but only just. When Symbol
is evaluated the name of the value is looked up in the Environment
namepace and the value stored there is returned.
1 2 3 4 5 6 7 8 9 10 11 |
public class SymbolNode extends Node { private String name; public SymbolNode(String name) { this.name = name; } @Override public Object eval(Environment env) { return env.getValue(this.name); } } |
The Environment class is just a simple mapping between strings and objects. Because Mumbler is lexically scoped, the Environment’s parent is searched if the name cannot be found. If the top parent is reached and the name is never found an exception is thrown.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
public class Environment { private final HashMap<String, Object> env = new HashMap<String, Object>(); private final Environment parent; public Environment() { this(null); } public Environment(Environment parent) { this.parent = parent; } public Object getValue(String name) { if (this.env.containsKey(name)) { return this.env.get(name); } else if (this.parent != null) { return this.parent.getValue(name); } else { throw new RuntimeException("No variable: " + name); } } public void putValue(String name, Object value) { this.env.put(name, value); } } |
MumblerListNode
is evaluated as a function call. This means all elements of the list are first evaluated by calling their respective eval
methods. The first element is then cast to a Function
and its apply
method is called with the rest of the list elements passed as arguments.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
public class MumblerListNode extends Node implements Iterable<Node> { // other code... @Override public Object eval(Environment env) { Function function = (Function) this.car.eval(env); List<Object> args = new ArrayList<Object>(); for (Node node : this.cdr) { args.add(node.eval(env)); } return function.apply(args.toArray()); } } |
Function invocation
This would be a good time to discuss what happens when user-defined functions (lambdas) are called. Remember that lambdas are really nothing more than a list of nodes so all we really need to do is evaluate the nodes in order then return the value of the final one. There are a couple of little twists. First, functions start a new namespace that have their outer function’s namespace as their parent. Second, functions can have arguments so we’ll need to put the arguments in the function’s new namespace before we start evaluating the body lest we get incorrect unknown variable exceptions. Here’s the Function
instance that’s returned when you use a lambda special form.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
new Function() { @Override public Object apply(Object... args) { Environment lambdaEnv = new Environment(parentEnv); if (args.length != formalParams.length()) { throw new RuntimeException( "Wrong number of arguments. Expected: " + formalParams.length() + ". Got: " + args.length); } // Map parameter values to formal parameter names int i = 0; for (Node param : formalParams) { SymbolNode paramSymbol = (SymbolNode) param; lambdaEnv.putValue(paramSymbol.name, args[i]); i++; } // Evaluate body Object output = null; for (Node node : body) { output = node.eval(lambdaEnv); } return output; } }; |
Special form evaluation
We’ve covered how Mumbler’s basic datatypes are evaluated; all that’s left are the few special cases: define
, lambda
, if
and quote
. Remember, in the Reader
when we encounter list forms that have these special symbols as their first element we replace the whole MumblerListNode object with a new SpecialForm
. This way, when we eval
them, we can have whatever we want happen. For example, define
takes the third element (a value) and stores it in the current namespace under the name given in the second element.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
private static class DefineSpecialForm extends SpecialForm { public DefineSpecialForm(MumblerListNode listNode) { super(listNode); } @Override public Object eval(Environment env) { SymbolNode sym = (SymbolNode) this.node.cdr.car; // 2nd element env.putValue(sym.name, this.node.cdr.cdr.car.eval(env)); // 3rd element return MumblerListNode.EMPTY; } } |
I showed the return Function
object for the LambdaSpecialForm
. The rest of LambdaSpecialForm
doesn’t really do much beside getting references to the outer function’s namespace and the formal parameters. Let’s show the rest.
1 2 3 4 5 6 7 8 9 10 11 12 |
private static class LambdaSpecialForm extends SpecialForm { public LambdaSpecialForm(MumblerListNode paramsAndBody) { super(paramsAndBody); } @Override public Object eval(final Environment parentEnv) { final MumblerListNode formalParams = (MumblerListNode) this.node.cdr.car; final MumblerListNode body = this.node.cdr.cdr; return new Function() { /* function definition goes here */ }; } } |
The other if
and quote
special forms do what you expect. If you’re interested, you can check out the code.
Builtin Functions
We’re in the homestretch of the implementation. The only thing left is creating our builtin functions. Since we’re defining the function entirely in Java we can skip things like mapping arguments and just get straight into evaluating. Most of our numerical functions can take variable arguments. For example, addition can take 0 or more arguments. With 0 arguments 0
is returned, with 1 argument the argument is returned and with more the sum of all arguments is returned.
1 2 3 4 5 6 7 8 9 10 |
static final Function PLUS = new BuiltinFn("PLUS") { @Override public Object apply(Object... args) { long sum = 0; for (Object arg : args) { sum += (Long) arg; } return sum; } }; |
Subtraction requires at least one argument. With one argument it negates the argument, with more it continually subtracts from the first argument.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
static final Function MINUS = new BuiltinFn("MINUS") { @Override public Object apply(Object... args) { if (args.length < 1) { throw new RuntimeException(this.name + " requires an argument"); } switch (args.length) { case 1: return -((Long) args[0]); default: long diff = (Long) args[0]; for (int i=1; i<args.length; i++) { diff -= (Long) args[i]; } return diff; } } }; |
You get the idea. You can see the implementation of the other builtin functions on GitHub.
Rounding out the REPL, we print out the result. There’s not much here. We just call System.out.println
and that’s that. Okay, there’s one little catch. Since we use the empty list as Mumbler’s “null” value, we just don’t print anything if that’s what’s returned in the REPL.
Mumbler in action
Now that we have a language spec and an implementation we can take it for a spin. We would start with Hello World, but we didn’t imlement strings! We’ll do the next best thing and use symbols.
1 2 |
(println (quote hello-world!)) ; 'hello-world! |
Close enough, and it works! Let’s try something a little more involved. How about the good ol’ fibonacci sequence.
1 2 3 4 5 6 7 8 9 |
(define fibonacci (lambda (n) (if (< n 2) 1 (+ (fibonacci (- n 1)) (fibonacci (- n 2)))))) (fib 10) ; 55 |
Great! We get the correct answer. We have a working program! As you can see, Mumbler would be easier to read if it had nice sugar like a function-define, but we can get by without it.
Benchmarks
With our working program what kind of performance do we get? More importantly, how does it compare to other languages that have had a lot of effort put into them to make them fast? Let’s take our fibonacci example and see how it performs in Racket and Node.js. The results:
rkt
--------------
1346269
computation time: 15
total time: 117
js
--------------
1346269
computation time: 16
total time: 82
That’s pretty fast. How does SimpleMumbler do?
mumbler
--------------
1346269
('computation-time: 1502)
total time: 1644
Ouch. Things aren’t looking so hot for our little interpreter. It performs about 100x slower than Racket or Node.js. Now, both Racket and Node.js have JIT compilers and SimpleMumbler doesn’t have any of that magic. What happens if we compare it to an interpreter with no JIT like CPython?
py
--------------
1346269
computation time: 385
total time: 400
Better. SimpleMumbler is only 4x slower than CPython. Not bad for a language quicky thrown together. CPython is written in C and has a bytecode interpreter that helps explain why it’s faster than our little language.
I’ll settle for fibonacci as a benchmark. Our terribly inefficient algorithm gives us plenty of avenues for improvement. We make a lot of function calls so if we improve that we should get a big speed up. Getting rid of boxed longs would really help.
Algorithms > Interpreter
Out of curiosity, I converted the Mumbler implementation to one that uses a much faster, linear algorithm.
1 2 3 4 5 6 7 8 9 10 |
(define fibonacci (lambda (n) (define iter (lambda (i n1 n2) (if (= i 0) n2 (iter (- i 1) n2 (+ n1 n2))))) (iter n 0 1))) |
This version runs through the fibonacci numbers sequentially instead of blowing up exponentially. This is an example of linear recursion vs tree recursion. You can read SICP for more info on this. So how does our smarter algorithm perform:
mumbler
--------------
1346269
('computation-time: 8)
total time: 169
Wow. Now that’s an improvement. The algorithm chosen has much more to do with speed than the language implementation. In it’s current state, SimpleMumbler could be “good enough” for many small tasks and give you reasonable speed. If you’re writing a DSL to glue code written in other faster languages maybe a simple interpreter is all you need. But let’s assume we’re writing a general purpose language that needs faster function calls and numeric operations.
Next Time
We have a working language and we have some baseline numbers to see if Truffle improves on our dead-simple interpreter. Next time we’ll actually start looking at Truffle and begin migrating SimpleMumbler over to it. Maybe along the way we’ll add some more benchmarks to get a better picture of our language’s speed.