Speeding Things Up With jRuby

By Jack Slingerland on 25 11 2012

If you hang around the Ruby community for long enough, you'll undoubtly hear talk about the the dreaded Global Interpreter Lock (GIL). The GIL is a "mutual exclusion lock held by the programming language interpreter thread to avoid sharing code that is not thread-safe with other threads."1. What that basically means is that each Ruby process can only be doing one Ruby thing at a time. Even if you are multi-threading in your program, Ruby is still only going to allow one thread to be executed at a time by interweaving their execution.

Ruby MRI (CRuby) Performance

To test CRuby's multi-threading performance, I contrived a small example program.

usage: ruby gil_test.rb <chunk_size>

This program takes my system's dictionary word list and reads it into an array. It then reads a list of 10,000 words obtained via Lorem Ipsum into another array. After that it breaks the word list into chunks, passes the chunks into threads, and then sees how many words from Lorem Ipsum are actual dictionary words. To see how the CRuby GIL affected performance, I ran 5 tests each on 7 different chunk sizes and then averaged the times for each chunk size.

CRuby Graph

Threads Chunk Size Time (in seconds)
1 10,000 139
2 5,000 139
3 ~3,333 147
4 2,500 144
5 2,000 142
8 1,250 141
10 1,000 142

Looking at our results, we can see that when using CRuby there is no real value in multi-threading. This is because the GIL won't let these threads run in parallel, so instead they have to negotiate for use of the Ruby process. So how can we speed this up? It's possible to fork the Ruby process, but then we'd be talking about multiprocessing instead of multithreading. If we don't want to mess with forking processes, we can keep using regular Ruby threads and just use JRuby as our interpretter.

JRuby

JRuby is an implementation of Ruby written in Java. For our purposes this is important because JRuby removes the GIL that CRuby has, allowing us to execute truly parallel code. In JRuby, Ruby threads map to Java threads, which then usually map to OS level threads, so performance is almost guaranteed to be higher with regards to threading than the CRuby version. Now that we don't have a GIL though, we need to be careful. The JRuby project on GitHub has some great documenation on writing programs with concurrency, but the list of 4 rules at the top distill the information quite well.

  1. Don't do concurrency.
  2. If you must do concurrency, don't share data across threads.
  3. If you must share data across threads, don't share mutable data.
  4. If you must share mutable data across threads, synchronize access to that data.

The nicest part about JRuby is that it runs most CRuby code without any changes, and that applies to this example as well. You can even run Ruby on Rails with JRuby, and then deploy to production with a Java server like Tomcat.

With the contrived example above, performance with CRuby wasn't so great. But once I used JRuby, things changed quickly. Performance with JRuby was over 3x faster in the worst case scenario, and over 16x in the best case.

JRuby Graph

Threads Chunk Size Time (in seconds)
1 10,000 39.8
2 5,000 18.2
3 ~3,333 12.6
4 2,500 12
5 2,000 10.6
8 1,250 8.6
10 ~666 9
15 1,000 9.4
20 500 9.4
50 200 9.4

Given that the slowest JRuby test (1 thread) was 3x faster that than slowest CRuby test, there is obviously something different in their implementations, because they probably should have similar times for single threaded operations. My guess is that the .includes? method is implemented differently, and probably takes advantage of parallelism in JRuby. Aside from that though, we notice significant gains with adding an additional thread, and then marginal gains up to the 8th thread. After the 8th thread, we actually start to lose speed. This is because I'm on a new MacBook Pro with a quad-core intel processor. Intel processors have "hyper-threading" on them, which effectively gives me 8 cores to max out. After I max out my 8th core, the OS has to start scheduling the cores a little more agressively, so we lose time because of context switches.

JRuby isn't a silver bullet though. Writing programs more complicated than my trivial example above is complicated, and the bugs can be hard to track down and fix. It's not always more performant for single threaded applications either, but it does give you access to the entire Java ecosystem. If you're going to be writing multi-threading programs in Ruby though, it's hard to beat the performance of JRuby.