Just-In-Time for Ruby 2.6, an explanation of compiled and interpreted languages

Image from https://blog.heroku.com/ruby-just-in-time-compilation

Ruby 2.6 was released a couple of weeks ago, featuring a brand new Just-In-Time (JIT) compiler . You can read up on the details of this new feature here, but if you’re looking for more background, I thought it would be helpful to give an introduction to the differences between interpreted and compiled languages and how JIT fits in.

First, what is a programming language? All languages are abstractions of machine code. Machine code is composed of bits — 0s and 1s — that can be stored and operated on by your machine’s hardware. It would be very inefficient for a human to read and write binary, which is why we invented programming languages.

The difference between interpretation and compilation lies in how a program written by a human is converted into executable instructions. It turns out that languages are not explicitly compiled or interpreted; compilation and interpretation are two different strategies that can be used to translate the code you write into the code a machine reads. As we’ll see when we get to the discussion of JIT, the lines between compilation and interpretation are becoming increasingly blurred.

Compilation

Put simply, compilation is the translation of a higher-level programming language into a machine’s language.

Let’s use C as an example of a language that is typically compiled. To run a C program, one must use compiler software, like gcc or clang, to compile the C source code into the appropriate machine code for your computer. Importantly, different computers have different CPU architectures, meaning that one computer handles the computation of 0s and 1s differently than another. A compiler translates source code into machine code for a specific architecture. Once your code is compiled, it can be run as many times as you’d like on any system with the same architecture, but if you update the source code, or want to run your program on a machine with different architecture, you will need to recompile.

A good analogy for a compiler is a human language translator. Similar to how a translator would translate a Spanish book into English, a compiler translates source code written by a human into machine code. Once a book has been translated, any English speaker can read it. However, if there are changes to the Spanish version, the English version will have to get retranslated and republished as well.

Interpretation

Unlike compilers which pre-translate source code into machine code before a program runs, interpreters translate code as they are executing it, line-by-line. Continuing with the previous analogy, computer interpreters are like, well, interpreters. They serve as the interface between a Spanish and English speaker interacting in real time, interpreting the language sentence by sentence.

Let’s use Ruby as an example of an “interpreted” language. Ruby 1.8 and earlier versions utilized Ruby utilized Matz’s Ruby Interpreter (MRI), which behaved as described above. It read in each line of Ruby, parsed and tokenized it, and then used a tree-based data structure to execute it. However, starting in version 1.9 (ca. 2011) Ruby switched to an implementation called YARV (Yet Another Ruby Virtual Machine). In this implementation, Ruby is pre-compiled into a simpler form consisting of bytecodes, named because they occupy 1 byte of memory. As a very simplified example, 2 + 3 would be converted to the bytecode for addition and would accept 2 and 3 as arguments. Once Ruby is converted to bytecode, a virtual machine executes the code line by line. Converting source code to bytecode offers significant speed advantages.

Python also utilizes bytecode, and in Python you can directly observe it in the .pyc files your program spits out. These files function like a cache; if the Python program is run again without changes, it can skip the compilation step and go straight to execution. While Ruby is also compiled to bytecode, its bytecode is only stored in memory rather than printed to a file.

Just-In-Time compilation

I just described how some contemporary interpreted languages now involve a bytecode compilation step, but there’s yet another way that compilation and interpretation are starting to blur, called Just-In-Time compilation.

The most popular Just-In-Time compiler is the Java Virtual Machine (JVM). Java is a statically typed language that can be directly compiled to machine code, but is commonly interpreted via the JVM. In the latter case, Java is compiled to Java bytecode by the Javac compiler, and then interpreted by the JVM. However, the JVM doesn’t interpret the bytecode line-by-line. Instead it tries to compose meaningful chunks of code, such as functions, all at the same time. It takes slightly longer to determine how to compose these chunks of code, but the execution is more efficient. This process is called Just-In-Time compilation, because it behaves like a compiler would, except it compiles at runtime. The benefit of using the JVM is that it retains some of the performance features of compiled languages, while making Java portable to different machines as an interpreted language. Java is the most popular language in the world, in large part because of the success of the JVM.

In addition to the JVM, there are other VM projects that make use of JIT compilation. PyPy is an example of a python interpreter with JIT, and of course Ruby now ships with an optional JIT feature.

Tradeoffs

Now that you have an introduction to Compiled vs Interpreted languages, what are the tradeoffs to each?

Speed

Compilation is generally much faster than interpretation. A compiled C program might execute a couple orders of magnitude faster than an interpreted program in Python or Ruby. However, Java’s hybrid JIT solution is quite efficient and runs can be nearly as efficient as a compiled program written in C.

Portability

In order to run your compiled program on a different machine’s architecture, you would need to recompile it. When a language is interpreted, it ships with it’s own instruction set that handles the specifications of your machine’s hardware architecture. The JVM is a good example of a technology that combines interpretation and compilation to maximize both the speed and portability of its language.

Dynamic vs Static typing

Since compilers have to translate and compose their programs directly into machine instructions, they are much more rigid. When declaring a variable, the compiler needs to know exactly what kind of variable it is and how much memory to allocate. This is why compilation generally requires static typing. In contrast, interpreters execute their programs line by line, so they can behave more flexibly.

In ruby, we can run write something like 2 + 3 or "a" + "b" and the interpreter will determine the type of the objects at runtime and call the right method for either integer addition or string concatenation.

Bugs/Debugging

Debugging is often easier with interpretation, because the program will execute until it encounters the error. An interpreter will notify the user exactly which line caused the runtime error, whereas bugs in compiled programs can be much more cryptic.

FAQs

Why is referring to a language as “interpreted” or “compiled” a misnomer?

Languages are defined by their syntax and data structures. Compilation and interpretation are two examples of implementations, e.g. a process to convert language syntax to a form that can be run on hardware. Languages are not inherently “interpreted” or “compiled”; the same language can be implemented using either approach.

Why then do people refer to Python as an “interpreted language” and C as a “compiled language”?

They are referring to the most common implementation/distribution of the language. However, Python can also be compiled and C can also be interpreted!

What is a virtual machine?

A virtual machine is anything that behaves abstractly like a computer, meaning it accepts as input a series of instructions, but is implemented through software as opposed to hardware. One common use of virtual machines is to run the OS of another system on a computer. For example, you own a windows laptop, but you want to emulate a linux operating system. You would utilize a software program that can covert between the OS of the system you want to run and the architecture of your physical computer. This is called a “system virtual machine”.

Not to be confused with the previous example, there are also “process virtual machines” e.g. the Java’s virtual machine (JVM) or Ruby’s virtual machine (YARV). These would be considered virtual machines because they accept an instruction set for executing bytecodes. The advantage of these VM’s are that they abstract away the hardware and provide a platform-independent programming environment.

Why do people generally refer to Python and Ruby as having interpreters whereas Java has a virtual machine? Doesn’t an interpreter also fit the definition of a virtual machine?

This distinction between an interpreter and virtual machine is mostly semantic or a “social construct” as this person phrased it. I think an interpreter fits the definition of a virtual machine. Even with a more constrictive definition, both Ruby and Python’s interpreters include a virtual machine that interprets bytecodes. Definitions aside, the JVM is fundamentally different from either the Ruby or Python interpreter. I’ve touched on a few of the reasons here, but the rest are beyond the scope of this article.

Why does compiling a language to bytecode before interpretation make it faster?

Bytecodes take up less memory than the full source code and are easier for the interpreter to execute. Indeed, when a language is compiled to bytecode, the interpreter has to parse everything once and convert it to bytecode, then reparse the bytecode to execute it. For simple pieces of code, this intermediate step does increase the total execution time by a small amount. However, for code that is executed repeatedly, think loops or reused functions, the bytecode step adds a significant speed advantage.

Further Resources:

BaseCS, a great blog on fundamental programming concepts: https://medium.com/basecs/a-deeper-inspection-into-compilation-and-interpretation-d98952ebc842

Ruby Under a Microscope, a great in-depth book on Ruby’s internals, accessible to programmers with limited C exposure: http://patshaughnessy.net/ruby-under-a-microscope

Computer Architecture course at Bradfield Academy, superior in-person course on computer architecture concepts: https://bradfieldcs.com/

Tyler Elliot Bettilyon’s youtube video, Tyler was my instructor at Bradfield and one of the best humans on the planet at explaining CS concepts https://www.youtube.com/watch?v=KsZLPTRSleI