Using Rust to Speed Up Your Ruby Apps: Part 2 — How to Use Rust With Ruby

In our previous blog post we discussed why the Vericred engineering team decided to rewrite part of our API in Rust to solve some performance issues.

Setup and calling Rust code from Ruby

In order for one language to speak to another you have to use a foreign function interface (FFI). There exists an FFI gem that allows you to call dynamically-linked native libraries from Ruby code. If you’re going to be passing complex objects back and forth over the FFI boundry you’re likely better off using a library like Helix or Rutie which allow you to easily serialize and deserialize objects to pass between the two languages. Additionally, there is Rutie-Serde which combines Rutie with the power of the Serde framework to make the task even simpler. Some of the Ruby objects we had to deal with in our hotspot were nested objects that were several layers deep. We opted to use Rutie-Serde and leverage Serde’s derive macro. This allowed us to avoid manually writing any code to deserialize Ruby objects into their respective Rust structs, which saved a lot of time and likely a lot of errors.

For our tutorial we are going to create a simple Rust library that is going to take in a collection of objects from a Ruby app, perform some simple calculations in parallel outside of Ruby’s GVL, and then return a value back to Ruby. It’s going to consist of a Gemfile that just has the Rutie gem included and a main.rb file for our Ruby code.

Gemfile

gem 'rutie', '~> 0.0.3'

main.rb

The first thing we need to do is load the Rust library that we will create in a moment. If you are working with a Rails project that has a config/application.rb file you’d add this code there instead. The first parameter in the call to .new()example_rust_lib, is the name of the Rust library we’re going to create and lib_path points to the location of the compiled library. In the call to .init() the first parameter is the c_init_method_name. This is the external entry point to your Rust library. The second parameter is the directory location of your Rust project, which in this case is just example_rust_lib. This initialization code will all make more sense as soon as we write our Rust library.

require 'rutie'
require 'securerandom'

module ExampleRubyApp
  Rutie
    .new(:example_rust_lib, lib_path: 'target/release')
    .init('Init_example_rust_lib', 'example_rust_lib')
end

Next is a class definition for the ParentRubyObjects that we’re going to pass to Rust. It consists of a number id, and an array of ChildRubyObjects which contains a string value that is set to a random string of hexidecimal digits when a new object is initialized. It’s worth noting that you don’t need to create special Ruby classes to pass to Rust. You can use existing classes, including ActiveRecord models, providing that you deserialize them properly on the Rust side.

class ParentRubyObject
  attr_reader :id, :children

  def initialize(id, children)
    @id = id
    @children = children
  end
end

class ChildRubyObject
  attr_reader :hex_value

  def initialize
    @hex_value = SecureRandom.hex
  end
end

We also have some code to generate data to send to Rust.

parent_objects = [].tap do |array|
  (1..1_000).map do |item_counter|
    id = item_counter
    children = Array.new(1000) { |_| ChildRubyObject.new }
    array << ParentRubyObject.new(id, children)
  end
end

We can now call our Rust library. You call the Rust library just as you would one written in Ruby. We pass in our array of ExampleRubyObjects and we are going to get back a number value.

calc_result = ExampleRustLib.calculate(parent_objects)
puts "The calculation result is #{calc_result}"

Let’s write some Rust…

You can place your Rust project anywhere you want in the file structure of your Ruby app. In our example we’re going to just place it in a folder called example_rust_lib off the root of our Ruby app. If you don’t have Rust installed follow the instructions here. We call cargo init to create a new Rust library.

cargo init example_rust_lib --lib

Your file structure should look something like this:

-example_ruby_app/
  -example_rust_lib/
    -Cargo.toml
    -src/
      -lib.rs
  -Gemfile
  -main.rb

Cargo.toml

The Cargo.toml file needs to be updated to include our dependencies and define two details about the library we are going to generate: its name and the crate-type which tells the compiler how to link crates. We want to create a dynamic library, so we use dylib.

[package]
name = "example_rust_lib"
version = "0.1.0"
authors = ["Edmund Kump <ekump@vericred.com>"]
edition = "2018"

[dependencies]
rayon = "1.2"
rutie = "0.5.5"
rutie-serde = "0.1.1"
serde = "1.0"
serde_derive = "1.0"

[lib]
name = "example_rust_lib"
crate-type = ["dylib"]

lib.rs

In our lib.rs file We need to include the dependencies we added to our Cargo.toml file. We include both Rutie and Rutie-Serde in our project. As you’ll see Rutie-Serde takes care of the deserialization of the Ruby objects, but we also need to deal with some Rutie objects, like Thread in our library.

#[macro_use]
extern crate rutie_serde;

use rayon::prelude::*;
use rutie::{class, Object, Thread};

use rutie_serde::rutie_serde_methods;
use serde_derive::Deserialize;

We also need to define the structs that Rutie-Serde will deserialize our Ruby objects into. The structs in our example are straightforward. Serde provides several attributes to handle more complex deserialization cases, such as renaming fields, setting default values, and making fields optional.

#[derive(Debug, Deserialize)]
pub struct ParentRubyObject {
    pub id: u64,
    pub children: Vec<ChildRubyObject>
}

#[derive(Debug, Deserialize)]
pub struct ChildRubyObject {
    pub hex_value: String
}

Next we need to call two macros. The call to the class! macro will generate structs so that we can use our Ruby class in Rust. We then call the rutie_serde_methods! macro with four parameters: The Ruby class, _itself, the Exception ruby class that will be used to instantiate any exceptions that get generated in our Rust code, and our Rust function that will be called when the methods we defined in our Init_example_rust_lib function called.

class!(ExampleRustLib);

rutie_serde_methods!(
    ExampleRustLib,
    _itself,
    ruby_class!(Exception),
    fn pub_calculate(parent_objects: Vec<ParentRubyObject>) -> u32 {
        parent_objects
            .iter()
            .flat_map(|parent| &parent.children)
            .map(|child| {
                child
                    .hex_value
                    .chars()
                    .map(|c| c.to_digit(10).unwrap_or_else(|| 0))
                    .sum::<u32>()
            })
        .sum()
    }
);

The calculation being performed in pub_calculate() is fairly simple and is just for illustrative purposes. We aren’t going to dive into how to write Rust code, but we’ll quickly highlight what’s going on here. The “calculation” being performed is to sum the value of all characters in the hex_value field that are valid base-10 numbers. For example, the sum of 2ef8c would be 10 because there are two numbers in that string: 2 and 8. We then sum this calculation for all ChildRubyObjects contained in all ParentRubyObjects. We accomplish this using provided methods for the Iterator trait like flat_map() and sum(). These methods are similar to those found in Ruby’s Enumerable mixin.

Finally, we are going to write the function that will serve as the entry point for the Ruby app to call. We need to provide the #[no_mangle] attribute on this function to tell the Rust compiler to not mangle the symbol name for the function that’s being exported so that we can actually call it from outside Rust as Init_example_rust_libextern “C” makes this function adhere to the C calling convention.

The name of this function needs to match the c_init_method name provided in the call to Rutie.init() in our Ruby app earlier. In the body, we define a new Rutie class called ExampleRustLib and map the calculate method that will be called in Ruby to the Rust function we defined in our rutie_serde_methods! macro above.

#[no_mangle]
pub extern "C" fn Init_example_rust_lib() {
    rutie::Class::new("ExampleRustLib", None).define(|itself| {
        itself.def_self("calculate", pub_calculate);
    });
}

Compile the library by running cargo build --release from the example_rust_lib directory and run your ruby app. You should see it print out a number. And that’s all you have to do to add Rust to your Ruby application!

If you are interested in benchmarking an analogous Ruby implementation add this to your Ruby code and see how much slower it is:

ruby_calc_result = parent_objects
                   .flat_map(&:children)
                   .sum do |child|
                     child.hex_value.split('').sum { |c| c.to_i(base=10) }
                   end

But wait there’s more…

There are two things you can do that can make your Rust code even faster. As mentioned earlier, the Ruby GVL may be contributing to performance issues. Even though we wrote our library in Rust, it’s still beholden to the Ruby GVL because a Ruby process has called it. Luckily, Rutie provides a way to execute our Rust code outside of the GVL with the Thread::call_without_gvl() function. Any code executed within the closure passed into call_without_gvl() will be executed free of interference from the GVL. As noted in the Rutie documentation, it’s important that you don’t interact with any Ruby objects while the GVL is released. Otherwise, you may encounter unknown behavior.

The second thing you can do to boost performance is to make your code multi-threaded. One of Rust’s many strongpoints is how well suited it is for concurrent and parallel programming. We’re going to use Rayon an easy to use, yet powerful parallelism library. To capture the maximum performance gains possible from running code in parallel you’ll want to do so outside of the GVL.

Let’s update our pub_calculate() function to run our calculation in parallel and outside of the GVL.

rutie_serde_methods!(
    ExampleRustLib,
    _itself,
    ruby_class!(Exception),
    fn pub_calculate(parent_objects: Vec<ParentRubyObject>) -> u32 {
        Thread::call_without_gvl(
            move || {
                parent_objects
                    .par_iter()
                    .flat_map(|parent| &parent.children)
                    .map(|child| {
                        child.hex_value
                            .chars()
                            .map(|c| c.to_digit(10).unwrap_or_else(|| 0))
                            .sum::<u32>()
                    })
                    .sum()
            },
            Some(|| {})
        )
    }
);

You’ll note that very little changed. Our original calculation code is now inside of a closure that’s passed to Thread::call_without_gvl() and parent_objects.iter() was changed to parent_object.par_iter(). Our example code won’t run much faster when executed in parallel outside the GVL. But given a more complex real-life scenario where your Ruby app is running on hardware under load inside a web server like Puma you would very likely see meaningful performance gains.

Hopefully this tutorial was helpful. In future blog posts we’re going to cover testing, error handling, and persisting to a database in Rust libraries that are being executed inside of Ruby apps.