Helix: One Year Later

This post is a write-up of the talk we gave at RailsConf 2017. You can find the slides here.

Last year at RailsConf, we introduced the Helix project, a SDK for writing native Ruby extensions in Rust. There are a lot of benefits and use cases for this, but since we already covered them in the previous post, we won't enumerate them again.

While we shipped a pretty good proof-of-concept, it was still very hard to use. It was missing a number of very basic features: we had very limited support for type coercions, we didn't support class methods, defining multiple classes, borrowed self and proper errors.

In short, nothing other than the demos really worked.

This year, we decided to focus our work on two fronts – writing good documentation and making it really easy to use Helix for some specific real world use cases.

On the documentation front, we are pleased to announce the Helix website. Here, you will find tutorials, documentation, demos and more.

As for latter, we decided to focus our effort on the use case of dropping Helix into a Rails app.

There are two reasons to focus on this use case. First, we could simplify the deployment story by assuming that you control the deployment environment and can supply a Rust compiler. Second, most Ruby use-cases are Rails apps in practice, so we could hit the sweet spot of making Helix usable for many Ruby applications while keeping our scope contained.

To be clear – Helix works fine outside of Rails (all of the examples in the repo are pure-Ruby examples), but we have prioritized on polishing the Rails experience.

To show you how it works, we have recorded a demo screencast. This video shows us building a Rails application with Helix and deploying to Heroku in under ten minutes. We even had time for tests!

Good Use Cases: CPU-Bound Work

Today, Helix is best for problems that use heavy computation and simple inputs. The boundary cost is still a little higher than we would like, but for problems that do a non-trivial amount of work in Rust, the cost of boundary crossing pays off quickly.

This isn't necessarily as big of a limitation as it might seem, because names of database tables, filenames and JSON objects are all "simple" inputs, so you can get pretty far with the types we already support in Helix.

Think about it this way: the Helix boundary is much cheaper and supports more types than a web request or even a background job, so if you could have moved the work to a micro-service or background job, you can make it work in Helix.

As an example, we built a demo that counts the number of times a specific word appears in a text file. When measuring how many times 'thee' appears in all of Shakespeare. This example takes advantage of Rayon, a Rust library that allows you to take existing loops and automatically parallelize them.

If you're thinking, "you can't parallelize 100% of all loops automatically," you'd be right. The cool thing about Rust is that the Rust compiler can automatically reject cases that won't work by leveraging the Rust ownership system.

Here's a slightly simplified version of the word search example, starting out using a normal sequential iterator (into_iter).

ruby! {
    class WordCount {
        def search(path: String, search: String) -> i32 {
            let mut file = File::open(path).expect("could not open file");

            // ...

            lines.into_iter()
                .map(|line| count_line(line, search))
                .sum()
        }
    }
}

A small tweak to that example (importing rayon and changing into_iter into into_par_iter) makes the loop parallel:

extern crate rayon;
use rayon::iter::{ParallelIterator, IntoParallelIterator};

ruby! {
    class WordCount {
        def search(path: String, search: String) -> i32 {
            let mut file = File::open(path).expect("could not open file");

            // ...

            lines.into_par_iter()
                .map(|line| count_line(line, search))
                .sum()
        }
    }
}

As we said before, one interesting characteristic of this example is that we pass a filename to Rust rather than a String containing the entire file, which avoids unnecessarily creating a garbage collected Ruby String containing the entire works of Shakespeare. This is generally a good trick for working with large amounts of data efficiently in Rust from Helix.

In practice, you wouldn't want to use Helix today for very chatty APIs that require a lot of back-and-forth communication with Ruby.

You can use Helix in a request, in a mailer, in a background job, in Action Cable, or in any part of Rails. Mailers and background jobs tend to be more CPU intensive, so they make really good places to try to improve performance using Helix.

Good Use Cases: Use Rust Libraries

Another really good reason to use Helix is if you want to use existing Rust libraries from your Ruby application. Because Servo is written in Rust (and Firefox shares a lot of code with Servo), there’s a lot of really production-quality libraries for dealing with web content.

As an example, we built a demo that inlines CSS into HTML, like for email. We were able to use Servo’s CSS parser and HTML parser, so we only needed a bit of code to make it all work.

One way to think about it is that it's like writing a binding to an existing C library, but a lot easier! Using html5ever, Servo's HTML parser from Ruby is much, much simpler than binding the libxml C library to Ruby.

Here's a simplified version of the demo. The cool thing is that you're still writing in relatively high-level style with iterators, objects and methods, but getting to use Servo's libraries for parsing HTML and CSS directly.

ruby! {
    class InlineCSS {
        def inline(html: String, css: String) -> String {
            let doc = kuchiki::parse_html().one(html);
            let mut parser = parse_css::CSSParser::new(css);
            let rules = ...;

            for rule in rules {
                let matching_elements = ...;

                for matching_element in matching_elements {
                    // insert style attribute
                }
            }

            // serialize ...
        }
    }
}

The Roadmap

Helix is still a young project with a lot of work still to be done. To give you a better sense of where we’re at, whether you can use Helix for your project, and how you can help contribute, we broke down the outstanding work in terms of a few expected use-cases.

The idea was to help us understand how far along we are at some high-level scenarios, and help users of Helix understand how adventurous they would need to be to try to use Helix in their own projects.

For example, we think Helix is already pretty good for greenfield projects (where you can control the exposed Ruby API and can ensure that your deployment environment has a Rust compiler). On the other hand, we still have some work to do before Helix is ready to ship to production in environments other than Rails or Heroku, and we have a number of items left to do in that category.

You can take a look at the current status for each of the various use-cases on the Helix roadmap page.