Engineering

Writing Ruby gems with Rust and Helix

Luca Guidi's profile picture Luca Guidi on

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 {
                    '&'  => "&",
                    '<'  => "&lt;",
                    '>'  => "&gt;",
                    '"'  => "&quot;",
                    '\'' => "&apos;",
                    '/'  => "&#x2F;",
                    _    => 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.

Ruby vs Rust performances comparison chart

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.

Share on Twitter and Facebook

Luca Guidi's profile picture

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.

Try us free for 30 days
4.5 stars

4.3 out of 5 stars.

Based on Trustpilot.com and G2.com reviews.