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_lib
. extern “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.