Three Compilers, One Concern Each
In the talk, the tenth rung of the ladder gets a single line and a "hold that thought":
typed Ruby ─▶ Spinel ─▶ C ─▶ machine code
That rung is three compilers standing on each other's shoulders: Roundhouse turns a Rails application into the subset of Ruby that Spinel, Matz's ahead-of-time compiler, accepts; Spinel turns that Ruby into C; the system C compiler turns the C into a native binary. Which sounds like three times the work, and is instead the opposite. Because Spinel emits C, it never wrote a register allocator. Because Roundhouse emits the Ruby Spinel compiles, it never wrote a garbage collector. Each compiler in the chain kills exactly one thing and hands everything else down. No layer does two jobs.
That's an easy sentence to nod along to. It's more interesting traced through one actual line of code, with the artifact at every stage.
The line
The blog's Article model — the whole thing:
class Article < ApplicationRecord
has_many :comments, dependent: :destroy
broadcasts_to ->(_article) { "articles" }, inserts_by: :prepend
validates :title, presence: true
validates :body, presence: true, length: { minimum: 10 }
end
Follow has_many :comments. In Rails, that line is metaprogramming: at
boot, it define_methods a reader that returns a lazy relation, backed
by schema reflection, query generation, and a cache. Nothing about it
exists until the application is running. It's also, as the talk argues,
a declarative spec — it says what, not how — which is exactly what
makes it compilable, if something is willing to answer at compile time
the questions Rails answers at boot.
Compiler one: Rails' dynamism dies here
Roundhouse's entire job is that answering. Here is what it emits for
has_many :comments, verbatim, in app/models/article.rb of the
transpiled tree:
def comments
return @comments_cache if @comments_loaded
stmt = Db.prepare("SELECT id, article_id, body, commenter, created_at, updated_at FROM comments" + " WHERE " + "article_id = " + Db.escape_int(@id))
results = []
while Db.step?(stmt)
results << Comment.from_stmt(stmt)
end
Db.finalize(stmt)
results
end
The DSL is gone. The reflection is gone — that column list was frozen
out of db/schema.rb at compile time. The laziness is gone. What
remains is an ordinary method: a query, a loop, a cache in two instance
variables. Ten lines of model became 337 lines of this, plus an RBS
sidecar recording what the compiler knows (def comments: () -> Array[Comment]).
Now look at the same method with garbage-collector glasses on. That
string concatenation allocates three intermediate strings. results = [] allocates. Every trip through the loop allocates a Comment.
Serving one page produces hundreds of short-lived objects, and nowhere
in the 200 emitted files is there a free, an ownership annotation, an
arena, or a lifetime. Roundhouse writes garbage-producing code with
total freedom, because garbage is the next compiler's problem.
One distinction worth pausing on: that explicit Db.finalize(stmt).
Roundhouse does manage resources — a statement handle is program
semantics, and closing it is the compiler's job. Memory is not
semantics; it's substrate. The emitted code closes its database handles
and never once frees a string.
Compiler two: Ruby's object model dies here
Spinel inherits that Ruby and kills the next abstraction down. Here's
where Article lands in the generated C:
struct sp_Article_s {
mrb_int cls_id;
sp_StrArray * iv_errors;
sp_RbVal iv_id;
...
sp_PolyArray * iv_comments_cache;
mrb_bool iv_comments_loaded;
};
@comments_loaded is now a bool at a fixed struct offset. Reading it
is a field load, not a hash lookup. And the method (abridged — the SQL
string-building is four sp_str_plus calls, each result rooted):
static sp_PolyArray * sp_Article_comments(sp_Article *self) {
SP_GC_SAVE();
sp_RbVal lv_stmt = sp_box_nil(); SP_GC_ROOT_RBVAL(lv_stmt);
sp_PolyArray * lv_results = NULL; SP_GC_ROOT(lv_results);
#line 303 "app/models/article.rb"
if (self->iv_comments_loaded) return self->iv_comments_cache;
/* ... build the SQL, each intermediate GC-rooted ... */
lv_stmt = sp_Db_s_prepare(_t2195);
lv_results = sp_PolyArray_new();
while (sp_Db_s_step_p(lv_stmt)) {
sp_PolyArray_push(lv_results,
sp_box_nullable_obj((void *)(sp_Comment_s_from_stmt(lv_stmt)), 82));
}
sp_Db_s_finalize(lv_stmt);
return lv_results;
}
This is where the concern Roundhouse ignored finally lands.
SP_GC_SAVE() and SP_GC_ROOT() maintain a precise shadow stack for
the collector. Every string pointer carries a marker byte at index −1 —
0xff for a rodata literal the collector must never touch, other
values for heap strings it owns. Spinel wrote a garbage collector
because C doesn't have one; that is precisely the debt it accepted so
the compiler above it didn't have to.
The types landed here too. The Ruby had no inline annotations; the C is
fully monomorphic. comments returns sp_PolyArray *;
Comment.from_stmt compiles to a direct call — no dispatch, no
method cache, because whole-program inference resolved the receiver at
compile time. (How that survives contact with send and friends is
its own post.)
And now look at what Spinel conspicuously does not do. lv_results
and lv_stmt are plain C locals. Nowhere in the 15,101 lines of
generated C is there a register name, a stack-slot decision, or an
instruction choice. The code is even allowed to look naive — chains of
single-use temporaries in statement expressions — because the C
compiler's optimizer will collapse them, allocate the registers, unroll
the loops, and inline the small calls. Spinel never wrote any of that
machinery, and never has to.
It doesn't even write the debugger. Those #line 303 "app/models/article.rb" directives flow into the C compiler's DWARF
output, so a native crash — or a breakpoint in lldb — reports the
Ruby file and line. The same trick the browser target plays with
source maps, played on the C toolchain, for free.
The contract runs both ways
Each layer's freedom is purchased by staying inside the contract of the
layer below. Roundhouse gets to ignore memory only because it emits
code Spinel can fully infer: no method_missing, no runtime define_method,
every call resolvable before the program runs. When the analysis can't
prove that, the construct doesn't get compiled dishonestly — it becomes
a diagnostic on the exact line. The subset is the price of the
inheritance.
And the inheritance is a property of the pair, not of Roundhouse. Point the same compiler at Rust and there is no collector downstairs — ownership must be threaded through every emitted line, and the emitter works hard for it. Point it at TypeScript and V8's collector shows up to do Spinel's job. Ten targets means ten different bundles of concerns inherited or accepted. The Spinel rung is just the cleanest place to watch the handoff, because the intermediate artifact at every stage is a file you can read.
The rewrite was always a compiler
Successful Rails applications have a well-known sequel: the rewrite,
proverbially in Go, for performance. The standard reading of that story
is "Go is fast, Ruby is slow." The decomposition the
bench page supports is more
interesting. Go can't do the metaprogramming. There is no has_many in
Go — so the rewrite team sits down and writes, by hand, the explicit
query, the resolved dispatch, the frozen column list. Line for line,
they write the 337-line file. The rewrite is a Futamura projection
performed by a team of humans at market rates, and Go's refusals are
the forcing function. The rewrite isn't fast because of what Go can do;
it's fast, in large part, because of what Go won't let you do.
The receipt is one act back in the talk: melt the framework while staying on CRuby — same interpreter, same JIT — and throughput goes up 8×, no Go involved. The language switch is the second, smaller factor.
What about the rest of the rewrite story — the concurrency, the deployment binary? The bench page happens to run that question as a controlled experiment: its Go row and its Spinel row are the same melted app, so whatever separates them is exactly the part of the rewrite that isn't the melt. On this workload it's close to a wash — each wins endpoints on throughput, neither by rewrite-narrative margins — while Spinel serves from a third of Go's memory (12 MB to 35) and ships a 594 KB binary to Go's 15 MB (Go's carries its SQLite inside; Spinel links the system one). Goroutines are Go's famous answer to concurrency; the Spinel row answers with a single process running a fiber per connection over an event loop — and holds the wash without spending the classic Unix escalation, pre-forked workers, at all. What genuinely remains on Go's side of the ledger is maturity — a network poller hardened at every connection count, hermetic cross-compilation, the race detector — not a story the compiled-Ruby path lacks in kind. (The bench digits drift as both compilers develop; the shape of the comparison is the claim.)
And the hand-executed projection has a cost the compiled one doesn't: the dynamism doesn't disappear in a rewrite — it gets hand-inlined everywhere, permanently. Hold that thought; it compounds below.
There is no interpreter, in stages
The talk frames the whole project as the first Futamura projection:
specialize the interpreter to one program and the interpreter melts
away. The stacked version of that claim: it doesn't melt once. Each
compiler in the chain commits, ahead of time, the answers the layer
above left open. Roundhouse commits Rails' boot-time answers — what
has_many defines, what the schema says, where the route goes. Spinel
commits the Ruby VM's answers — object layout, method dispatch, types.
The C compiler commits the machine's — registers, instructions,
addressing. What's left over is only what genuinely cannot be known
before execution, and each leftover lives in exactly one place: the
row data in SQLite, the collector in Spinel's runtime, the branch
predictor in the silicon.
For the blog, the whole cascade prices out as: a 10-line model, to 337
lines of plain Ruby with an RBS sidecar (200 files for the whole
application), to 15,101 lines of C, to a 594 KB native binary
whose only dynamic dependencies are libc and SQLite — and whose every
URL is DOM-diffed against real Rails in CI. You can hold every stage in your hands: the
typed-Ruby tree
downloads as a tarball whose README commands CI runs verbatim, and
spinel main.rb -S prints the C to stdout.
Ten lines is the context window
Run the cascade backwards and it's a compression ratio — roughly 34× from the emitted Ruby to the model alone. That number is usually presented as a compiler flex. It's actually an ergonomics number, because everything that maintains this system — human or robot — has a context window.
"Add a byline column with a presence validation" is a one-line diff
and a one-thought change in the 10-line model. In the emitted Ruby it's
a couple dozen edit sites across three files — the accessors,
from_row, from_stmt, initialize, the column lists inside half a
dozen SQL strings, the row class, the params class, the validator. In
the C it's all of that plus a struct layout and the GC rooting. Same
change, three sizes of thought. Humans hold about seven things; LLMs
hold a token budget; both reason best at the top of the cascade. A
compiler that melts the framework doesn't just save execution time — it
saves reasoning tokens, for both species of maintainer. That isn't
incidental to the robots act of
the talk: the agent cadence works because everyone in it — me, Claude,
Matz, his agent — operates on specs and diagnostics, never on the
15,101-line artifact.
Which is not to say the expanded forms are waste. The emitted Ruby is
the best documentation of what has_many actually means — this post's
receipts depend on reading it. The working arrangement is: write at 10
lines, read at 337 when you need the receipt, open the C only when
you're filing a compiler bug. Every layer readable on demand; no layer
maintained by hand.
Now the deferred cost of the Go rewrite comes due. The rewrite hand-inlines the melt, so the team's maintenance altitude moves down a layer and stays there — every future feature is reasoned about at 34× the size, forever. The compiled melt is regenerated on every build; the reasoning surface stays ten lines. That's the honest framing of ejection, too: roundhouse will hand you the expanded form as readable, idiomatic code that's yours to evolve — but taking it means choosing, with eyes open, to move your altitude down. The proverbial rewrite is ejection without the compiler, and without the choice.
The reason one person and the robots they live with can build a ten-target compiler at all is the same reason the ten-line model is the right place to reason: no fight is ever picked with more than one abstraction at a time. Kill the one thing the layer exists to kill; hand the rest down; trust the compiler below to do the same — and let everything with a context window, carbon or silicon, work at the top.
Roundhouse is open source: dual-licensed MIT / Apache-2.0. Issues and discussion welcome.