Whole Routes in a Box
Yesterday's post ended with
Roundhouse's futamura target at 1.00× Rails on the Lobsters benchmark,
and a ceiling for it of 2.5–3×. A day of measuring moved both. This post
explains the name first, since the idea behind it is the whole plan.
Futamura projections
In 1971 Yoshihiko Futamura made an observation about partial
evaluation. A partial evaluator, or specializer, takes a program and the
part of its input that is known ahead of time, does every computation
that depends only on that part, and writes out a smaller program, the
residual program, that waits for the rest. Give a power function the
exponent 3 and you get back x * x * x, with the loop gone.
Futamura noticed what happens when the program being specialized is an interpreter:
- Specialize an interpreter with respect to the program it is about to run, and the residual program is that program, compiled. The interpreter's work of deciding what each piece of source means has been done once, ahead of time.
- Specialize the specializer with respect to the interpreter, and you get a compiler.
- Specialize the specializer with respect to itself, and you get a compiler generator.
These are the three Futamura projections. The image at the top of this post draws them as tombstone diagrams, the T-shapes compiler writers use to show a program standing on what runs it.
Rails is, operationally, an interpreter for your application. Your routes, associations, callbacks and templates are data it consults on every request: which filters apply to this action, what SQL this scope chain means, which partial this name refers to. None of those answers changes between requests. Specialize Rails with respect to your application and, by the first projection, what comes out is your application with the interpretation done ahead of time.
Roundhouse doesn't have an automatic specializer. It builds the first
projection by hand, one specialization at a time, from an analysis that
reads the whole application before it runs. What it can't decide stays
residue, and the futamura target's rule is that residue runs on Rails,
with your gems. The name records where the target is headed, not a claim
that it's there.
Where specializing inside Rails runs out
The specializations so far work inside Rails: a static query chain handed to Rails' own statement cache, a preload tree planned once. They keep Rails' Active Record objects, so gems hooked into those objects keep working. Profiling Lobsters says that strategy has a ceiling near 2×, not the 2.5–3× I estimated yesterday. Even faster attribute reads inside Rails' own model classes would be worth 3–4% at most. Most of what is left of Rails' cost is the objects themselves, and they come off only when a whole request never builds them.
Roundhouse already writes a version of the application that never builds
them: the ruby target, plain Ruby on Roundhouse's own small runtime,
which runs this benchmark about 4× faster than Rails. The question was how
to use it for some requests and leave the rest to Rails.
Ruby::Box
Both have to run in one process, and they can't share a namespace.
Roundhouse's runtime defines its own ActiveRecord, ActionController
and ActiveSupport, which collide with Rails' own.
Ruby::Box is what
makes this possible. It arrived in Ruby 4.0 as an experimental feature,
enabled by starting Ruby with RUBY_BOX=1. A box is a separate space for
classes, modules, globals and loaded files. Code loaded into a box defines
its constants there; the core classes are shared, but a monkey patch made
in one box isn't seen from another. Roundhouse's output goes into a box,
and its ActiveRecord is not Rails'.
Whole routes
The futamura target now ships that output alongside your application. A
middleware just after Rails' session store hands a GET request to it when
the analysis can vouch for the whole route, and Rails serves everything
else. In Futamura's terms, the residue is now chosen per route: a route is
either fully specialized or fully Rails.
Vouching is a reachability question. Start from the action, follow the calls through its filters, views, models and helpers, and if any of them reaches code that Roundhouse reports as an error, meaning code it might translate wrongly, the route stays on Rails. For each route it leaves to Rails, the build says why, with the call path to the first error:
GET /top home#top: reaches 1 error(s), first app/controllers/home_controller.rb:267
`>` with incompatible operand types: Integer | String | ... | nil? > Integer
via HomeController#process_action -> HomeController#top
That turns Roundhouse's list of errors for an application into a list of routes. Fixing an error moves the routes it was holding back.
Three things cross between the two applications, and no model object ever does. Rails' session goes in as a hash and comes back with whatever changed, so logins and CSRF tokens work across the boundary. The flash goes the same way. And the database connection is Rails' own, lent for the request. It matters that it's the connection and not the file: a box loads its own copy of every C extension, so if the boxed code opened the database itself there would be two copies of SQLite on one file in one process, which SQLite warns against. Through Rails' connection there is one, and the boxed code also sees Rails' transaction, so in a test it reads the test's fixtures.
Measured
On Lobsters, the analysis clears 48 of 84 GET actions, 40% of the benchmark's requests:
| ms per pass | |
|---|---|
| Rails | 1,264 |
Rails, started with RUBY_BOX=1 |
1,490 |
| Rails with whole-route replacement | 1,085 |
That is Ruby master with YJIT, on a different machine from yesterday's
tables. The routes served from the box run 2.3–4.7× faster than Rails; the
story page goes from 14.1 ms to 3.0, the comments page from 17.7 to 4.6.
The pass as a whole gains only 1.17×, because starting Ruby with
RUBY_BOX=1 slows Rails itself by about 19% before any box is created,
and every route left to Rails pays that. When an earlier prototype sent
every GET to the box regardless of the analysis, the pass took 377 ms,
3.3× Rails. The difference between 1.17× and 3.3× is the error list.
Real-blog's own test suite passes on the output with its GET requests served this way, and all 26 Lobsters pages we compare render the same content as Rails.
Ruby 4.1
This needs Ruby 4.1. On 4.0.x, Rails crashes under RUBY_BOX=1 as soon
as it loads net/imap; Ruby master doesn't. That makes Ruby::Box a hard
dependency of this part of the target, and its cost to Rails the next
thing worth understanding. The other open items are writes, which stay
Rails' so that gems' model callbacks keep firing, and a cache the two
sides don't yet share.
How it works, with the full numbers, is in the specialization notes.