Ruby’s performance has been improving a lot, version after version… and the Ruby development team is making every effort to make Ruby even faster!
One of these efforts is the 3×3 project.
The goal?
Ruby 3.0 will be 3 times faster than Ruby 2.0.
Part of this project is the new MJIT compiler, which is the topic of this article.
MJIT Explained
MJIT stands for “Method Based Just-in-Time Compiler”.
What does that mean?
Ruby compiles your code into YARV instructions, these instructions are run by the Ruby Virtual Machine.
The JIT adds another layer to this.
It will compile instructions that are used often into binary code.
The result is an optimized binary which runs your code faster.
How It Works
Let’s explore how MJIT works to understand it better.
You can enable the JIT with Ruby 2.6 & the --jit
option.
Like this:
ruby --jit app.rb
Ruby 2.6 comes with a set of JIT-specific options that will help us discover exactly how it works. You can see these options by running ruby --help
.
Here’s a list of the options
- –jit-wait
- –jit-verbose
- –jit-save-temps
- –jit-max-cache
- –jit-min-calls
This verbose option looks like a good starting point!
We are also going to be using --jit-wait
, this makes Ruby wait until the compilation of JIT code is done before running it.
During normal operation the JIT compiles code in a worker thread & it doesn’t wait for it to finish.
Here’s the command you can run to test this:
ruby --disable-gems --jit --jit-verbose=1 --jit-wait -e "4.times { 123 }"
This prints:
Successful MJIT finish
Well, that’s not very interesting, is it?
The JIT is not doing anything.
Why?
Because by default, the JIT only comes into action when a method is called 5 times (jit-min-calls
) or more.
If we run this:
ruby --disable-gems --jit --jit-verbose=1 --jit-wait -e "5.times { 123 }"
Now we get something interesting:
JIT success (32.1ms): block in <main>@-e:1 -> /tmp/_ruby_mjit_p13921u0.c
What does this say?
The JIT compiled a block because we called it 5 times, this tells you:
- How long it took to compile (
32.1ms
), - Exactly what was compiled (
block in <main>
) - The file that was generated (
/tmp/_ruby_mjit_p13921u0.c
) as the source for this compilation
This file is C source code which is compiled into an object file (.o
) & then into a shared library file (.so
).
You can get access to these files if you add the --jit-save-temps
option.
Here’s an example:
This is my current understanding of how the JIT works:
- Count method calls
- When one method is called 5 times (default for
jit-min-calls
) trigger JIT - A C file that contains the instructions for this method is created (these are YARV instructions, but inlined)
- Compilation happens in the background (unless
--jit-wait
) using a regular C compiler like GCC - When the compilation is done the resulting shared library file is used when this method is called
Let’s see how effective this is.
Testing MJIT: Is It Really Faster?
The goal of MJIT is to make Ruby faster.
How good is it doing that right now?
Let’s find out!
First, microbenchmarks:
Benchmark | Results (Compared to Ruby 2.6 without JIT) |
---|---|
while | 8x faster |
while with string append | 10% faster |
while with multiplication (Integer) | 4x faster |
while with multiplication (Bignum) | 20% slower |
string upcase | 10% faster |
string match | 2% slower |
string match? | 10% faster |
array with 10k random numbers | 20% faster |
It seems like performance is all over the place, but there is something we can deduce from this…
MJIT really likes loops!
But how does it fare with a more complex application?
Let’s try with a simple Sinatra app:
require 'sinatra' get '/' do "apples, oranges & bananas" end
It may not look like much, but this little bit of code runs over 500 different methods. Enough to give the JIT some work to do!
To be specific, this is Sinatra 2.0.4 with Thin 1.7.2.
You can run the benchmark with this command (apache bench):
ab -c 20 -t 10 http://localhost:4567/
These are the results:
You can tell from these that Ruby 2.6 is faster than 2.5, but enabling the JIT makes Sinatra 11% slower!
Why?
I don’t know, it may be because overhead introduced by JIT, or because the code is not well optimized.
My testing with a C profiler (callgrind) reveals that the use of JIT optimized code (the compiled C files that we discovered earlier) is very low for Sinatra (less than 2%), but it’s very high (24.22%) for the while statement that gets a 8x speed boost.
Results for the while benchmark with JIT:
Results for Sinatra benchmark with JIT:
This may be part of the reason, I’m not a compiler expert so I can’t make any conclusions from this.
Summary
MJIT is a “Just-in-Time Compiler” available in Ruby 2.6, it can be enabled with the --jit
flag. MJIT is promising & can speed up some small programs, but there is still a lot of work to do!
If you liked this article don’t forget to share it with your Ruby friends 🙂
Thanks for reading.
For the last few minor releases I’ve been compiling ruby with jemalloc for improved memory allocation. Would this interfere with jit in ruby 2.6?
That’s an interesting question Tim.
As far as I understand I don’t think it will interfere because the JIT doesn’t change how memory allocation works. The best way to find out is to download a preview version of Ruby 2.6 & try it out 🙂
Strange, I’m not getting any improvement using JIT in any of the test cases, only slow downs. I’m using 2.6.0 as well as trying with current ruby-edge off github. I did have to create my own tmp directory in my home directory because it was failing with permission problems to use the regular stock /tmp (this is RHEL 7.5) for some strange reason. Humm, I don’t doubt that JIT is awesome and can be very effective in some cases, but for some reason, it just isn’t working here. (fyi 8 cores x Intel(R) Xeon(R) Silver 4110 CPU @ 2.10GHz)
Thx for the article, btw!