intertwingly

It’s just data

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.