A few months ago the engineering team here at Vericred noticed some severe performance issues in one of our API endpoints written in Ruby on Rails. We isolated the problem to a specific piece of code that we felt would be a good candidate to be rewritten in Rust and it worked out great! We were able to reduce execution time by over 98% with virtually zero regression bugs in the new code. Over the next few blog posts we’re going to share why we decided to use Rust, how to integrate Rust into an existing Ruby codebase, and how executing code outside of Ruby’s GVL can improve performance.
Introducing a New Language To Your Team
The topic of how to introduce a new language to an engineering organization successfully is a complex topic in and of itself. For more information on introducing Rust to an organization check out Ashley Williams’ talk on introducing Rust at NPM.
Vericred’s API, as well as the majority of our internal tools, are built with Ruby on Rails. This fits our needs most of the time. Ruby is an easy language to work with and the ecosystem of tools like Rails, ActiveRecord, and RSpec allow our engineering team to quickly and reliably deliver new features to our customers. But, Ruby isn’t necessarily the best option when you’re dealing with large amounts of data, or when you need things to be fast. What we gain in engineer productivity we potentially lose with performance. This is usually an acceptable trade-off, but we needed something for when speed was the priority.
Rust stood out to us as an excellent complement to Ruby for several reasons:
- It excels in areas where Ruby is lacking. Namely, performance, parallelization, and memory utilization.
- It’s almost the opposite of Ruby: It’s type-safe, natively compiled, and doesn’t have a garbage collector. Adding Rust to our tech stack would truly diversify it.
- Despite being so different from Ruby it doesn’t feel foreign to work with. While it isn’t as easy to ramp up as Ruby, Rust is a modern language that puts great emphasis on ergonomics. You also don’t have to trade away memory and concurrency safety to achieve performance gains.
- It has excellent support for extending Ruby code via FFI.
Of course, knowing that Rust can be a good addition to your engineering team’s toolbox and knowing when it’s the right tool for the job are two different things.
When to Use Rust
It’s important to understand why your code isn’t performing well enough in order to determine if Rust can help solve your problems. For example, if the root of your problem is slow database queries, Rust isn’t going to be much help. What made Rust a compelling choice for our specific hotspot was that it was a task that should execute significantly faster in parallel. We needed to perform several hundred independent calculations and return a collection of objects that would persist to a database. Yet, when we tried to parallelize the work in Ruby we saw only miniscule improvements. We believe the primary culprit was Ruby’s Global VM Lock (sometimes referred to as Global Interpreter lock) and thread contention. We are going to discuss the GVL in greater detail in a future post but to put it simply, the GVL only allows one thread to execute at a time. To make matters even worse, our API runs in Puma, the concurrent web-server. So our code was not only in contention with itself, but threads serving other requests.
To solve our problem, we needed three things:
- To be able to execute our calculations faster than our current Ruby code.
- To be able to execute our code free of the Ruby GVL.
- To be able to truly parallelize our code and leverage all the CPU cores available to our server.
We were pretty confident that Rust could accomplish this. In our next post we explore how to use Rust within your existing Ruby app.