Writing Ruby gems with Rust and Helix
For the last couple of years I've been hearing how Rust can be useful to write faster Ruby gems. Last year, when Godfrey Chan gave his presentation about rewriting String#blank?
with Rust, I was enthusiastic 😻 to try it out, but I failed miserably. It was too early to experiment with it.
This year, Godfrey and Yehuda Katz talked again about this topic, announcing the version 0.5.0
of Helix: a bridge between Ruby and Rust. On the Helix website there is documentation that covers getting started with it and Rails, but it lacks of clear examples to build a Ruby gem 😞.
So I looked at the examples trying to figure out how to build a Ruby gem with it. This time I succeeded 🎉, and here's how I did it.
Rewriting Ruby
This year, instead of String#blank?
I decided to rewrite a single function (Hanami::Utils::Escape.html
) and to package it into a standalone Ruby gem. While blank check returns a simple output (a boolean), HTML escaping allows to experiment with two-way communication between the two languages: a string as input and a string as output.
Setup
First, I generated a new Ruby gem.
$ bundle gem hanami_utils_escape
Helix at this stage doesn't support Ruby modules. In case you want to try, please make sure that the gem name will result into a top-level class (in the example: HanamiUtilsEscape
).
Ruby implementation
Next I extracted the code into the new gem, specs included.
I had to simplify the new method signature a bit. The original Hanami::Utils::Escape.html
returns a Hanami::Utils::Escape::SafeString
instance, but for now Helix is only able to return Ruby's String
, true
, false
, Float
, Integer
, and nil
types from a Rust function.
This is limiting and I really hope that in the future they will be able to support at least Array
, and Hash
too. 🤞
Another limitation to consider is encoding: Helix supports only UTF-8 strings. The caller of the Ruby function must ensure that the input of the function will have that encoding.
Adding Helix
Adding Helix requires some knowledge of how Rust and Cargo (the package manager) work.
I added helix_runtime
to hanami_utils_escape.gemspec
:
spec.add_dependency "helix_runtime", "~> 0.5.0"
At the root of the project, I created the Cargo.toml
, which is the Rust equivilent of Bundler's Gemfile
:
[package]
name = "hanami_utils_escape"
version = "0.0.1"
[lib]
crate-type = ["cdylib"]
[dependencies]
helix = "0.5.0"
The name of the Rust package must match the name of the Ruby gem.
The crate-type
setting tells Cargo how to build the Rust code. In this case, cdylib
is a dynamically linked library. Helix will use this dynamic library to provide a low level implementation to Ruby.
Because Rust is a compiled language, I wanted to automate the build process so the build would happen before the tests are run.
# lib/tasks/helix_runtime.rake
require 'helix_runtime/build_task'
HelixRuntime::BuildTask.new("hanami_utils_escape")
task default: :build
And then from Rakefile
:
require 'bundler/setup'
require 'rspec/core/rake_task'
import 'lib/tasks/helix_runtime.rake'
namespace :spec do
RSpec::Core::RakeTask.new(unit: :build) do |task|
task.pattern = FileList['spec/**/*_spec.rb']
end
end
task default: 'spec:unit'
In this way, each time I run bundle exec rake
, the Rust code is compiled for Helix to inject into Ruby, and once it is compiled the tests will start.
To require the native version of the code, I added the following lines:
# lib/hanami_utils_escape.rb
require "helix_runtime"
begin
require "hanami_utils_escape/native"
rescue LoadError
warn "Unable to load hanami_utils_escape/native. Please run `rake build`"
end
And finally I seeded the Rust lib with the bare minimum of code:
# src/lib.rs
#[macro_use]
extern crate helix;
Rust implementation
At this point I was able to implement the function with Rust.
#[macro_use]
extern crate helix;
ruby! {
class HanamiUtilsEscape {
def html(input: String) -> String {
let mut result = String::new();
for c in input.chars() {
let fallback = c.to_string();
let s: &str = match c {
'&' => "&",
'<' => "<",
'>' => ">",
'"' => """,
'\'' => "'",
'/' => "/",
_ => fallback.as_str()
};
result.push_str(s);
}
return result
}
}
}
Running bundle exec rake
this time will use the Rust implementation! 🎉
Benchmarks
The main reason why you, as a Rubyist, may be interested in Rust/Helix is its performance. Let's compare the Ruby and Rust implementations with different sized inputs.
For all the samples: strings with 100
, 1,000
and 10,000
chars, the Rust implementation is twice as fast as Ruby.
Conclusion
My Rust knowledge is very basic. To make that code to compile required spending a couple of frustrating hours. Most likely it isn't the best implementation, and it can be probably improved.
On the production side, I haven't found a way to package the final gem to be pushed against Rubygems.
The performance characteristics are appealing, but there is too much effort to make it to work and too much trial-and-error in the process. I suggest to use Helix in production for small, focused tests. For the time being, we are not considering to use it on DNSimple.
For the complete implementation of my experiment, please check the source code.
Luca Guidi
Former astronaut, soccer player, superhero. All at the age of 10. For some reason now I write code.
We think domain management should be easy.
That's why we continue building DNSimple.
4.3 out of 5 stars.
Based on Trustpilot.com and G2.com reviews.