Beta Ziliani 29 Dec 2021

Crystal's interpreter – A very special holiday present

The awaited Crystal interpreter has been merged. To use it, you need to compile Crystal with a special flag and, at the time of writing, the official releases (.deb, .rpm, docker images, etc.) are not being compiled with it.

This post doubles as a F.A.Q. for this special feature. Let’s start from the very beginning:

Why Crystal needs an interpreter

While many will find this obvious, it’s useful to point out two specific features that an interpreter might enable:

  1. In principle an interpreter should start executing code faster, since the codegen phase is skipped (see below), allowing to quickly test the result of some code without needing to recompile. It should be noted that the interpreter is quick for short programs that need to run only once, but compiled mode provides much more performance and should be preferred for most production use cases. Also, while the behavior of interpreted and compiled programs might differ in some aspects (due to the system), we intend to keep the differences down to a minimum, and most programs should behave exactly the same in interpreted and compiled mode.
  2. An interpreter improves the experience of debugging, being an ad-hoc tool to the language (unlike the generic lldb).

What is the current status?

The interpreter is currently on an experimental phase, with lots of missing bits. The reason to merge it at this early stage is to enable a proper discussion of interpreter-related PRs and speed up its development a bit. For those willing to try it out, we welcome interpreter-related issues taking into consideration the already known issues aforelinked.

How does it work?

The original PR answers it:

When running in interpreted mode, semantic analysis is done as usual, but instead of then using LLVM to generate code, we compile code to bytecode (custom bytecode defined in this PR, totally unrelated to LLVM). Then there’s an interpreter that understands this bytecode.

How do I invoke it?

⚠️ This is not set in stone!

Assuming you’ve compiled Crystal passing the flag interpreter=1 to make, you can invoke the interpreter using two modes right now:

  • crystal i file.cr

This commands runs a file in interpreted mode, so if write_hello.cr contains the following:

File.write("/tmp/hello", "Hello from the interpreter!")
puts "done"

Invoking crystal i write_hello.cr will produce the file /tmp/hello and print "done" to the stdout.

  • crystal i

This command opens an interactive crystal session (REPL) similar to irb from Ruby. In this session we can write a command and get its result:

icr:1:0> File.read_lines("/tmp/hello")
=> ["Hello from the interpreter!"]

(Note: the highlighting is not yet part of the interpreter.)

Debugging a program

In any of these two modes, you can use debugger in your code to debug it at that point. This is similar to Ruby’s pry. There you can use these commands (similar to pry also):

  • step: go to the next line/instruction, possibly going inside a method.
  • next: go to the next line/instruction, doesn’t enter into methods.
  • finish: exit the current method.
  • continue: resume execution.
  • whereami: show where the debugger is.

For instance, if we add debugger between the write and the puts in write_hello.cr, we get the following after interpreting the file:

From: /tmp/write_hello.cr:3:6 <Program>#/tmp/write_hello.cr:

    1: File.write("/tmp/hello", "Hello from the interpreter!")
    2: debugger
 => 3: puts "done"

pry>

And if we step, we can see the method form the standard library being called:

pry> step
From: /Users/beta/projects/crystal/crystal/src/kernel.cr:385:1 <Program>#puts:

    380:
    381: # Prints *objects* to `STDOUT`, each followed by a newline character unless
    382: # the object is a `String` and already ends with a newline.
    383: #
    384: # See also: `IO#puts`.
 => 385: def puts(*objects) : Nil
    386:   STDOUT.puts *objects
    387: end
    388:
    389: # Inspects *object* to `STDOUT` followed
    390: # by a newline. Returns *object*.

pry>

At this point, we might be curious: what are the objects that we passed to this method? We step once again to have the variable in scope, and we issue:

pry> objects
{"done"}

How much faster does the interpreter load a program?

We’re definitely missing benchmarks, more so given that not many shards can be successfully interpreted. Testing on a few random files from the standard library and Crystal Patterns, it loads them (and executes them, see below) between 50 and 75% faster than it takes when compiling them (comparing time crystal <file> vs. time crystal i <file>). This depends significantly on how much time Crystal takes on the common steps to the compiler and the interpreter, like parsing and semantic analysis.

How fast (or slow) does it run?

As Ary, its creator, showed in Crystal Conf 1.0, it runs sufficiently fast—for an interpreter that is. Of course, it’s not nearly as efficient as a mature interpreter implementation like Ruby’s, yet in our preliminary tests it runs fast enough for the expected use cases. For instance, it can handle millions of integer additions within the second. This said, using the interpreter for processing intensive tasks is definitively discouraged, as that’s the task for a compiled program.


To conclude, merging the PR is the first step to get a working interpreter in Crystal and, more importantly, it’s a testament of the will of the Crystal team to improve the developer experience.