Speeding up Ruby MRI with Rust
Let me start by saying I really like Ruby. I tend to agree with the statement saying Ruby is optimized for developer happiness. However, nothing
comes for free. Programming ecstasy is a double-edged sword and writing slow Ruby is as easy as it is pleasant.
You could try very very hard to squeeze as much CPU juice as you can from your program by reducing memory allocations to the bare minimum at the cost of abstractions. You could also try reciting prayers to Mats before you hop in your bed at night. But at the end of the day, no matter how hard you try, you will wake up in the morning and Ruby will still be the same interpreted language. The YARV VM improved things considerably but sometimes it just cannot provide you with the kind of performance you would get from a compiled solution. Oh yeah, and the GIL.
All is not lost! There are solutions! You can keep enjoying the tickles of Ruby and optimize hot paths in your code.
“C! C! You can use C!”
I could also brush my teeth with vinegar. But no thanks.
You see, C is a fully loaded Desert Eagle with no safety. Sure, you can probably put together a trivial extension and hope for the best but as soon as you try doing something slightly more involved, you’d be handing that gun to a monkey. Because we’re all monkeys after all.
Double free, use after free, null pointer dereference, uninitialized memory,
buffer overflows, data races… These are all Damocles swords hanging above your program just waiting to fall and crush it into little pieces of Undefined
And this is where Rust enters the picture.
Rust can adopt C calling conventions so everywhere you would use C, you can use Rust instead. It also means that you can call Rust functions from C and vice-versa. In other words, you don’t need to write C code ever again!
This post is not meant to explain to you in details what Rust is, as there are
plenty of resources on the subject, but rather how to integrate Rust with Ruby.
To get more intimate with Rust, I highly recommend reading the Rust Programming Language book. In a nutshell, Rust is a memory-safe and data-race free LLVM backed no-GC RAII language. That’s a heck of a mouthful but simply put it’s a nerd dream come true.
Proof of concept
I used the i18n gem since it’s a good candidate for a rewrite but any CPU-bound gem could be a prime prospect here.
This gem allows you to internationalize your application. You load some YAML files in memory containing your translated strings and then you fetch the one you need for the current locale, passing in options such as interpolations if needed.
I have nothing against that gem as per se. It does what it’s supposed to do. However, I needed a guinea pig and this is the kind of gem that is used
everywhere in a codebase. Improving the performance of your hot paths is what is going to benefit you the most.
The functionalities I decided to support and benchmark are the following.
These are the most basic features of the gem and I did not want to rewrite the whole thing as this is mostly for experimentation purposes.
The Rust implementation
Let’s start with a simple description of what the Rust implementation of the i18n library looks like.
Under the hood, translation files are flattened, merged by locale and converted to hash maps. For hash maps I’m using the hashbrown library, a Rust port of Google’s SwissTable hash map which is faster than the standard library implementation. The hash map keys are the flattened paths joined by dots (e.g. “house.owner.greeting”) and the values are structs holding the different bits of the original translation string for faster interpolations.
When requesting a translation, we get the hash map corresponding to the requested locale and fetch the translation struct corresponding to the key. If the request is pluralized, we append a pluralization suffix to the key (e.g. “.zero”, “.one”, “.other”).
Finally we perform the interpolations and return the resulting string value.
Once we have the backend ready, we need to be able to initialize it and call it from Ruby.
To create and manipulate Ruby objects from Rust I used a library called Rutie. The Rutie internals are pretty neat and simple. The library is divided in three separate layers.
rubysyslayer. This layer exposes Ruby C functions to Rust. These are the C functions that are called under the hood when you run your Ruby code. For example, you could call
rb_ary_popwith an array pointer to pop an item from the array (the equivalent of calling
bindinglayer. This layer wraps the C functions exposed in
rubysysto make them safe. Rust has this concept of unsafety and calling C functions is considered unsafe as the compiler can’t ensure the C code is safe. It’s up to the developer to hide this unsafety in safe abstractions that respect the invariants.
classlayer. This layer provides high level abstractions on top of the
bindinglayer. It’s the layer you should be using the most. It provides types such as
Hashand others which are all wrappers around Ruby objects.
Rutie also provides macros on top of the
class layer. I decided to not make use of them and write my own for fine-grain control over performance.
Exposing Rust to Ruby
The first step when defining a method that can be called from Ruby is converting the Ruby arguments pointer to Rust types. We could also manipulate Rutie structs directly in our code but in this case I preferred not leaking Ruby concerns outside of the bridging layer.
Once the arguments are converted, we do whatever we want to do with the data provided to us in Rust land. In our case, this entails initializing an i18n backend or fetching translation strings.
The final step is returning an
AnyObject to Ruby land.
AnyObjects can be, as the name indicates, any Ruby object. We can also wrap a Rust struct in a Ruby object before returning it. This is what we are using here to pass the initialized backend struct to Ruby upon calling
And then in Ruby land, I used thermite to initialize the extension. Thermite is a Rake-based helper for building and distributing Rust-based Ruby extensions.
There’s not much more to it than this little snippet of code, which makes the
I18nRs class visible to our Ruby code.
And that’s all there is to it!
You can now initialize an
I18nRs backend from Ruby with
I18nRs.new, pass it around and get translated strings with
I18nRs#t until the backend is garbage collected.
Now the crunchy part.
But first, a mandatory disclaimer about benchmarks: benchmarks can be bended, twisted and interpreted in different ways. I did not have any bad intentions measuring these. I did my best, within my capabilities, to compare apples to apples. If a pear slipped in the basket it was wholly unintentional.
These benchmarks were measured on a MacBook Pro 2.5 GHz Intel Core i7 with 16 GB of DDR3 RAM.
For Ruby I used the i18n gem. I haven’t looked at the code much but it’s possible the implementation is not optimized and this could be inflating numbers in our favour. However the gem is well maintained therefore I would assume it is representative of Ruby code in the wild.
Also, both the Ruby and Rust versions were tested with the same dataset.
Are you feeling warm inside? Do you have butterflies in your stomach? This is what Rust does to you, you can’t escape it, you just need to embrace it.
I hope this post gave you ideas about how you can leverage the magic powers of Rust to write fast and safe Ruby extensions. I believe there is no reason to rely on C when Rust provides you with the same speed but adds safety to the mix. Thanks to its fantastic type system and functional flavours, Rust is also a joy to write.
Let me know if you have any success stories doing something similar in production, I would be very curious to hear about them!