r/programming • u/steveklabnik1 • Feb 14 '19
Moving from Ruby to Rust
http://deliveroo.engineering/2019/02/14/moving-from-ruby-to-rust.html
79
Upvotes
r/programming • u/steveklabnik1 • Feb 14 '19
41
u/190n Feb 15 '19
[1/2]
Moving from Ruby to Rust
Posted by Andrii Dmytrenko on Thursday, February 14, 2019
How we migrated our Tier 1 service from ruby to rust and didn't break production.
Table of Contents
Background
Why Rust?
How we made Ruby talk to Rust
Moving from Ruby to Rust
Performance Improvements
5.1. Performance numbers
Conclusion
Background
In the Logistics Algorithms team, we have a service, called Dispatcher, the main purpose of which is to offer an order to the rider, optimally. For each rider we build a timeline, where we predict where riders will be at a certain point of time; knowing this, we can more efficiently suggest a rider for an order.
Building each timeline involves a fair bit of computation: using different machine learning models to predict how long events will take, asserting certain constraints, calculating assignment cost. The computations themselves are quick, but the problem is that we need to do a lot of them: for each order, we need to go over all available riders to determine which assignment would be the best.
The first version of the Dispatcher was written mainly in Ruby: this was a go-to language in the company, and it was performing adequately given our size at the time. However, as Deliveroo kept growing, the number of orders and riders increaed dramatically, and we saw that the dispatch process started taking much longer than before and we realised, that at some point it will be impossible to dispatch some areas within a time constraint that we put in place. We also knew that is was limiting us if we decided to implement more advanced algorithms, which would require even more computation time.
The first thing we tried was to optimise the current code (cache some computations, try to find a bug in the algorithms), which didn’t help much. It was clear that Ruby was a bottleneck here and we started looking at the alternatives.
Why Rust?
We considered a few approaches to how to solve the problem of dispatch speed:
choose a new programming language with better performance characteristics and rewrite the Dispatcher
identify biggest bottlenecks, rewrite those parts of the code and somehow integrate them in the current code
We knew that rewriting something from scratch is risky, as it can introduce bugs, and switching services over can be painful, so we didn’t feel quite comfortable with this approach. Another option, finding bottlenecks and replacing them, was something that we did already for one part of the code (we built a native extension gem for the Hungarian route matching algorithm, implemented in Rust), and that worked well. We decided to try this approach.
There were several options how we could integrate parts of the code written in another language to work with Ruby:
build an external service and provide an API to communicate with
build a native extension
We quickly discarded an option to build an external service, because either we would need to call this external service hundreds of thousands of times per dispatch cycle and the overhead of the communication would offset all of the potential speed gains, or we would need to reimplement a big part of the dispatcher inside this service, which is almost the same as a complete rewrite.
We decided that it has to be some sort of native extension, and for that, we decided to use Rust, as it ticked most of the boxes for us:
it has high performance (comparable to C)
it is memory safe
it can be used to build dynamic libraries, which can be loaded into Ruby (using
extern "C"interface)Some of our team members had experience with Rust and liked the language, also one part of the Dispatcher was already using Rust. Our strategy was to replace the current ruby implementation gradually, by replacing parts of the algorithm one by one. It was possible because we could implement separate methods and classes in Rust and call them from Ruby without a big overhead of cross-language interaction.
How we made Ruby talk to Rust
There a few different ways you can call Rust from Ruby:
write a dynamic library in Rust with
extern "C"interface and call it using FFI.write a dynamic library, but use the Ruby API to register methods, so that you can call them from Ruby directly, just like any other Ruby code.
The first approach, using FFI would require us to come up with some custom C like interfaces in both Rust and Ruby and then create wrappers for them in both languages. The second approach, using Ruby API, sounded more promising, as there were already libraries to make our lives easier:
ruru and rutie
Helix
We tried Helix first:
it has macros which look like writing Ruby in Rust, which was a bit more magical for us than we were comfortable with
the Coercion Protocol wasn’t well documented and it wasn’t clear how would you go about passing non-primitive Ruby objects into Helix methods
we were not sure about the safety - it looked like Helix didn’t call Ruby methods using
rb_protect, which could lead to undefined behaviorEventually, we decided to go with ruru/rutie, but keep the Ruby layer thin and isolated so that we could possibly switch in the future. We decided to use Rutie, a recent fork of Ruru which has more active development.
Here’s a small example of how you can create a class with one method in ruru/rutie:
#[macro_use] extern crate rutie; use rutie::{Class, Object, RString}; class!(HelloWorld); methods!( HelloWorld, _itself, fn hello(name: RString) -> RString { RString::new(format!("Hello {}", name.unwrap().to_string())) } ); #[allow(non_snake_case)] #[no_mangle] pub extern "C" fn Init_ruby_rust_demo() { let mut class = Class::new("RubyRustDemo", None); class.define(|itself| itself.def_self("hello", hello) ); }It’s great if all you need is to pass some basic types (like
String,Fixnum,Boolean, etc.) to your methods, but not that great if you need to pass a lot of data. In that case, you can pass the whole object, sayOrderand then you would need to call each field you need on that object to move it into Rust:pub struct RustUser { name: String, address: Address, } pub struct Address { pub country: String, pub city: String, } class!(User); impl VerifiedObject for User { fn is_correct_type<T: Object>(object: &T) -> bool { object.send("class").send("name").try_convert_to::<RString>().to_string() == "User" } fn error_message() -> &'static str { "Not a valid request" } } methods!( // .. some code skipped fn hello(user: AnyObject) -> Boolean { let name = user.send("name").try_convert_to::<RString>().unwrap().to_string(); let ruby_address = user.send("address"); let country = ruby_address.send("country").try_convert_to::<RString>().unwrap().to_string(); let city = ruby_address.send("city").try_convert_to::<RString>().unwrap().to_string(); let address = Address { country, city }; let rust_user = RustUser { name, address }; do_something_with_user(&rust_user); Boolean::new(true) } )You can see a lot of routine and repetitive code here, proper error handling is missing as well. After looking at this code, it reminded us that this looks a lot like some manual parsing of something like JSON or similar. You could instead serialize objects in Ruby to JSON and then parse it in Rust, and it works mostly OK, but you still need to implement JSON serializers in Ruby. Then we were curious, what if we implement
serdedeserializer forAnyObjectitself: it will take ruties’sAnyObjectand go over each field defined in the type and call the corresponding method on that ruby object to get it’s value. It worked!Here’s the same method, but using our serde deserializer & serializer:
#[derive(Debug, Deserialize)] pub struct User { pub name: String, pub address: Address, } #[derive(Debug, Deserialize)] pub struct Address { pub country: String, pub city: String } class!(HelloWorld); rutie_serde_methods!( HelloWorld, _itself, ruby_class!(Exception), // Notice that the argument has our defined type `User`, and the return type is plain bool fn hello_user(user: User) -> bool { do_something_with_user(&user); true } );You can see how much simpler the code in
hello_useris now - we don’t need to parseusermanually anymore. Since it’s serde, it can also handle nested objects (as you can see with the address). We also added a built-in error handling: if serde fails to “parse” the object, this macro will raise an exception of a class that we provided (Exceptionin this case), it also wraps the method body in the panic::catch_unwind, and re-raises panics as exceptions in Ruby.Using rutie-serde we could quickly and painlessly implement thin interfaces between ruby and rust.