The Ruby JRuby Was Built to Run

mooreds1 pts0 comments

The Ruby JRuby Was Built to Run

Rails developers who know JRuby have a fair question for Roundhouse: what does compiling Rails applications to other languages offer that two decades of running Rails on the JVM doesn't already provide?

Let me start with the part that isn't in dispute. JRuby is mature — a complete Ruby implementation, production-proven, with a compatibility story measured in decades. Roundhouse is weeks old, handles a subset of Rails, and its emit is still calibrated to one fixture within that subset. If you need to ship a Rails application on the JVM this quarter, JRuby is the answer and Roundhouse is not a candidate. Nothing below changes that.

What follows is instead an observation about where the two approaches meet — because they meet in a more interesting place than "competitors."

What JRuby already wins

JRuby's value proposition has always been: keep your app, change the runtime. And for traditional HTML-rendering Rails applications, the win is real. On the Roundhouse benchmark — the same small Rails 8 blog application measured across every target, methodology in Numbers Without Conclusions — stock Rails serving the HTML index does 481 req/sec under CRuby with YJIT, and 1,057 req/sec under JRuby, at less than half the median latency. A 2.2× throughput gain from changing nothing but the runtime, consistent with what production JRuby shops have reported for years.

(On the JSON endpoint the two are at rough parity — 1,272 vs 1,080 — which is worth keeping in mind whenever someone generalizes about "JRuby performance" in either direction. Where the request spends its time determines what the JVM can win back.)

The experiment the benchmark happens to contain

Roundhouse's bet is different: keep your app, change the code — where "change the code" is shorthand for something specific. An application serving thousands of requests per second is making the same decisions, with the same outcomes, on every one of them. Every decision whose answer cannot differ between requests can be made once, at transpile time, instead. Roundhouse reads a Rails application, makes those decisions, and emits the residue as standalone, metaprogramming-free projects — in Rust, Go, Crystal, Kotlin, Swift, Elixir, TypeScript, Python, and Ruby itself. That last target is the interesting one here, because the emitted Ruby is just Ruby. It runs anywhere Ruby runs.

So the benchmark includes a jruby row: the same emitted tree the CRuby target runs — byte-identical except that the SQLite backend swaps the sqlite3 C extension for the JDBC driver — executed under JRuby 10. That makes for an unusually clean experiment. Same source application. Same emitted code. Two runtimes.

HTML index (/articles):

configuration<br>req/sec<br>p50 (ms)<br>RSS (MB)

Rails on CRuby+YJIT<br>481<br>128.9<br>328

Rails on JRuby<br>1,057<br>55.7<br>1,169

Roundhouse emit on CRuby+YJIT<br>5,283<br>12.0<br>135

Roundhouse emit on JRuby<br>26,108<br>2.3<br>982

JSON show (/articles/1.json):

configuration<br>req/sec<br>p50 (ms)<br>RSS (MB)

Rails on CRuby+YJIT<br>1,272<br>47.6<br>416

Rails on JRuby<br>1,080<br>57.0<br>1,502

Roundhouse emit on CRuby+YJIT<br>7,700<br>8.0<br>165

Roundhouse emit on JRuby<br>46,886<br>1.3<br>1,048

Reading the 2×2

Those tables are a 2×2: two codebases × two runtimes. Read them that way and two different things are going on — and they compose.

The first effect: stripping the interpretive layers. Hold the runtime constant and swap the codebase. Under CRuby+YJIT, the emit serves the HTML index 11× faster than Rails; under JRuby, the same swap is worth 25× (on JSON: 6× and 43×). And this is not the emit doing less of the work that produces the response. The Rails index action is one line:

@articles = Article.includes(:comments).order(created_at: :desc)<br>The emitted action is that line's mechanism, spelled out:

def index<br>stmt = Db.prepare("SELECT id, body, created_at, title, updated_at FROM articles" +<br>" ORDER BY created_at DESC")<br>results = []<br>while Db.step?(stmt)<br>results Article.from_stmt(stmt)<br>end<br>Db.finalize(stmt)<br>__comments_ids = results.map { |a| a.id }<br>__comments_stmt = Db.prepare("SELECT id, article_id, body, commenter, created_at, updated_at" +<br>" FROM comments WHERE article_id IN (" +<br>Db.escape_int_list(__comments_ids) + ")")<br># … drain, group by article_id, a._preload_comments(group) …<br>@articles = results<br>if request_format == :json<br>render(Views::Articles.index_json(@articles), content_type: "application/json")<br>else<br>render(Views::Articles.index(@articles, @flash[:notice], @flash[:alert]))<br>end<br>end<br>Same two queries — the ordered select, the IN-list eager load — and the same response: a cross-target compare gate holds every emitted app's output to Rails' (HTML compared DOM-node-for-DOM-node with whitespace preserved, JSON value-for-value). The JSON view tells the same story: jbuilder's json.extract! article, :id, :title, :body, :created_at, :updated_at becomes two dozen lines of direct string appends with real escaping and Rails-canonical timestamps. Both of these lowerings — and a third, the...

jruby rails roundhouse json articles ruby

Related Articles