intertwingly

It’s just data

Spinel on Rails


Spinel is Matz's ahead-of-time Ruby compiler: it reads Ruby, infers types across the whole program, emits C, and from there a native binary with no runtime dependencies. Roundhouse reads a Rails application and emits standalone projects in nine languages — one of which is Ruby. The two were started a month apart, by people who didn't know each other's work. I wrote about that coincidence twice in April: once when it looked like two answers to one moment, and again two days later when the overlap turned out to be sharper than I'd said — not merely two compilers drawing the same line, but two compilers that wanted the same intermediate language.

That second post ended on a bet. Today that bet paid off. But first a quick start:

curl -sL https://rubys.github.io/roundhouse/browse/spinel.tgz | tar xz
cd spinel
make build
sqlite3 storage/development.sqlite3 < db/seed.sql
./build/blog

This is the quintessential Rails blog demo modernized with Tailwind CSS, Turbo Streams, and Action Cable — open multiple windows, make a change, and watch pages update live. All running as a Spinel application which is automatically generated from the original Rails source.

The bet

Here is what I wrote on April 27, after first testing whether Spinel could compile Roundhouse's contract fixture:

The bet I'm making: the architectural shape is right, the maturity gaps close on both sides, and within a few iterations the round trip works end to end.

I listed five places where C compilation failed — module-typed globals not propagating across scope, polymorphic method arguments collapsing to one type, default-argument methods emitting a single signature, empty intermediate inheritance classes dropping parent struct fields, polymorphic instance variables typing as integer and corrupting later assignments. I filed the most isolated one as a six-line repro and called it "a starting point for the dialog."

"Within a few iterations" turned out to be the wrong unit. It took 239 closed issues. But the round trip runs: lower a Rails blog to the Spinel subset, hand the result to Spinel, get back a native executable that serves the application. The bet is settled, and the way it settled is more interesting than a clean win would have been.

What "a few iterations" actually cost

The five gaps I could see in April were the ones visible from outside — the failures you hit reading the documented subset and trying the fixture once. They were the tip.

Feeding a non-trivial Rails-shaped program through to compiled C and running it under load surfaced a long tail the first attempt never reached. Of the 239 issues I've filed against Spinel that are now closed, only seven were requests to widen the subset or questions about process — an FFI, Fiber.storage, GC introspection, a server transport, Hash#to_h, an issue-cadence meta-issue, and a repro-archive workflow. The other 232 were the compiler catching up to its own documented subset: polymorphic dispatch, inference precision, garbage-collector safety, struct layout, and codegen plumbing.

Representative failures, by category:

Category What broke Examples
Polymorphic dispatch A cls_id switch routing to the wrong method body, or dropping a builtin case when an instance method shadowed it #1451, #1447, #1443, #1437
Inference precision A method inferred as Integer emitting a nil box; a nil-guard failing to narrow a nullable type #1432, #1417, #1397
GC safety under load A fresh argument collected before its parameter was rooted; a string-concat loop running RSS to multi-GB; foreign pointers marked as heap objects #1450, #1445, #1439, #1314
Struct & inheritance layout A subclass struct missing a field present on the value-typed parent #1442, #1415
Codegen plumbing Ruby locals emitted with no C declaration; a block losing self; bare super to a module-qualified superclass #1435, #1436, #1413

The honest summary: the documented subset was sound. What was incomplete was the distance between documented and delivered — the inference and codegen catching up to what the subset already promised. That distance was an order of magnitude larger than the five gaps suggested, and it lived almost entirely below the subset line, in the machinery that turns conforming Ruby into correct C.

Two of those buckets are worth dwelling on, because they connect directly to the numbers below. The GC-under-load issues — a use-after-free during row materialization, a string-concatenation loop that leaked to multiple gigabytes — are failures you only see when you push real request traffic at the binary. They're also why the memory story later in this post is trustworthy rather than aspirational: the small resident footprint was earned by closing them, not handed out for free.

The collaboration

On April 28 I opened a meta-issue asking Matz a process question: should I file each gap as its own minimal repro, the way #49 was framed, or batch them, or hold them until other inference work landed?

His answer set the cadence that produced everything since. File each one as its own minimal repro, he said: "small, contained reproducers are easy to triage, the fix usually lands in one or two commits, and the issue/PR pair leaves an audit trail in the type-inference area that future contributors can find." Title each by symptom, not mechanism — even when two reports share a root cause, "two repros tend to bisect to two different fix sites." Order them however; he'd pick them up as they came. The 239 separate issues are that instruction, carried out.

The part that mattered most wasn't about workflow. In April I'd described Spinel itself — not its documentation — as the arbiter of what's in the subset. He called that "the right read," and put the principle in his own words: "The subset is co-evolving with the runtime and the codegen, and the only way to know what's actually in scope is to feed real programs at it and see what bounces." And when a report turns out to be one he'd rather not support, he said, "I'll say so explicitly in the issue so you don't keep working around it on the Roundhouse side."

That is the operational definition of the seam these two compilers share. The subset isn't a fixed specification both projects happen to target. It's a line discovered empirically, by collision, with the language's author marking the boundary when a collision reaches it. What he offered for the Rails blog itself was measured: "a great forcing function and a different shape of input from the small repros that drive most of the issue tracker today," and so "a Spinel-backed Rails blog would be welcome alongside the existing demos." That is exactly the right size of claim to rest on. He endorsed the method and agreed to referee the boundary. None of this is an endorsement of Roundhouse's architecture or of the numbers below; he hadn't seen them, and the value of his agreement is precisely that it's about process and scope, not results.

The numbers

With the round trip closed, the same emitted Ruby application can be run three ways: under CRuby, under JRuby, and — now — compiled by Spinel to a native binary. Same source Rails app, same lowered Ruby, three runtimes spanning the frontier. The measurements below are single-worker, on the real-blog fixture, same methodology as Numbers Without Conclusions. These tables are a snapshot, captured 2026-06-19. All three runtimes improve release over release, and Roundhouse's lowering keeps changing what it emits, so the continuously regenerated numbers will drift from what's frozen here — Spinel's drifting most, since it's the youngest and the one I'm actively filing against.

HTML index (/articles):

target req/sec p50 (ms) p99 (ms) RSS (MB) req/sec/GB
Roundhouse → Ruby, CRuby 3,596 17.68 22.19 126 29,238
Roundhouse → Ruby, JRuby 28,839 2.09 4.35 927 31,856
Roundhouse → Spinel, native 10,064 6.33 6.86 12 865,545

JSON show (/articles/1.json):

target req/sec p50 (ms) p99 (ms) RSS (MB) req/sec/GB
Roundhouse → Ruby, CRuby 5,373 11.63 17.88 159 34,604
Roundhouse → Ruby, JRuby 53,330 1.16 3.18 1,032 52,925
Roundhouse → Spinel, native 24,052 2.58 3.47 13 1,925,834

Read these as one frontier with three points on it, not a leaderboard. The same lowered Ruby that beats CRuby — Spinel is about 2.8× CRuby on the HTML index and 4.5× on JSON, with no runtime underneath it at all — then splits at the two extremes depending on what you hand it to.

JRuby takes the throughput corner: on the JSON endpoint it serves roughly 53,300 requests a second at a 1.2 ms median, about 2.2× Spinel's throughput at less than half the latency. Spinel takes the footprint corner: 13 MB resident against JRuby's 1,032 — a factor of about 80 — as a standalone binary with no VM, no JIT, and no warmup. Each corner pays for its win with the other's currency. JRuby spends a gigabyte of heap to let a warmed JIT specialize the hot path; Spinel spends peak throughput to fit in a container you could run a thousand of.

The column that makes the trade legible in a single number is req/sec/GB — throughput normalized by memory. There Spinel leads JRuby by roughly 36× on the JSON endpoint. Not because it's faster; because it delivers respectable throughput in one-eightieth the RAM. Which corner you want depends entirely on whether you're latency-bound or density-bound, and the point of having both targets is that you don't decide at the language level.

A different way to put it: this is the JRuby post's tradeoff, measured on the opposite axis. That post held the codebase constant and swapped runtimes to show the JIT dividend. This one swaps in a third runtime that has no JIT at all, and the dividend reappears as its mirror image: the same static, monomorphic, request-invariant Ruby that the JVM JIT rewards is the Ruby an ahead-of-time compiler can turn into tight C. One input shape, two ways to cash it in.

Two specializers, stacked

The reason the seam isn't a coincidence is worth stating in the vocabulary the JRuby post reached for. Rails is, operationally, an interpreter for your application — it consults routes, associations, validations, and templates as data on every request. Specialize that interpreter with respect to the application it interprets and you get the compiled application; that's the first Futamura projection, and it's what Roundhouse does.

Spinel does the same move one layer down. CRuby is an interpreter for the Ruby language; Spinel specializes it with respect to a particular program and emits native code. Two specializers, operating at adjacent altitudes of the interpreter stack — framework, then language. Roundhouse specializes Rails away and leaves Ruby; Spinel specializes Ruby away and leaves a binary.

Chain them and you get the pipeline that runs today:

Rails app → Roundhouse → metaprogramming-free Ruby → Spinel → C → native binary

This is why the two subsets meet. The thing that makes a program ingestible by the lower specializer is the absence of the upper interpreter's dynamism. Spinel can't compile eval, send over runtime data, method_missing, or define_method — and a Rails app is built on exactly those. But it's built on them through Rails. The application doesn't call define_method to make its attribute accessors; has_many does, at load time, on the application's behalf. Discharge Rails at build time — evaluate its metaprogramming once, during lowering, and emit only the residue — and the features Spinel forbids leave with the framework that was using them. What's left is the application's own logic, which mostly never reached for those primitives directly.

"Mostly" is load-bearing, and it's where the boundary lives. An app that calls send in its own code, or pulls a metaprogramming-heavy gem that isn't Rails, keeps some dynamism that survives the discharge — and that's what would keep it off Spinel even after Rails is gone. The chain composes on the intersection of what both halves support. Stacked specializers always meet at a "no dynamic features" seam, because eliminating the upper interpreter's dynamism is precisely what manufactures input the lower one can accept. Matz and I drew the same line from opposite sides not because we coordinated — we didn't — but because there's only one line to draw.

The subset, and why it grows on both ends

Both halves are subsets. Roundhouse emits a subset of Rails; Spinel compiles a subset of Ruby; the native pipeline runs where those subsets overlap. That sounds fragile. It isn't, because the two are not coupled to each other — they're each coupled to the same stable interface in between: metaprogramming-free Ruby.

Roundhouse grows by mapping more of Rails onto that interface. Spinel grows by mapping more of that interface onto native code. Neither needs the other's cooperation to expand, and divergence in their rates costs performance, never correctness: anything in Roundhouse's subset but outside Spinel's still runs as ordinary Ruby under CRuby or JRuby. The native binary is the reward for landing in the intersection; missing it means a fallback to a Ruby interpreter, not a broken build. It's the ordinary virtue of a narrow waist — the thing in the middle holds still so the two ends evolve on independent schedules.

That waist began as a debugging aid. The Ruby target was meant to be a control: emit Ruby, run it under CRuby, and check it behaves like Rails before introducing the additional variable of a different target language. An identity leg, to isolate the transpiler's bugs from cross-language semantic mismatches. It only later turned out that the discipline making it a good control — behaviorally faithful to Rails, free of dynamism that could let runtime semantics leak in — is the same discipline that makes it the Ruby a JIT can optimize and the Ruby Spinel can compile. Three uses, one shape, and I designed it for only the first. The convergence is the finding; that I didn't aim at it is what makes the other two count as confirmation rather than wishful construction.

The honest gaps

The native round trip runs on the fixture and on programs the fixture's lowering covers. It is not "compile any Rails app to a binary" yet, and the usual caveats apply without exception: this is a CPU-bound microbenchmark of a small blog on a Hetzner bare metal server — a 6-core/12-thread AMD Ryzen 5 3600 with 64 GB of RAM, running Ubuntu 24.04 — with local SQLite and no production I/O, and your application almost certainly does not transpile today.

Three things specific to this post.

The measurements are single-worker. That's the cleanest way to compare per-instance efficiency, but it's also the configuration that flatters Spinel's memory story most — a multi-worker Rails deployment amortizes some overhead, and the JVM's resident cost is easier to justify when shared across many workers. Read the 80× memory gap as a per-instance figure.

The same CSRF and cookie discount from the prior post is unchanged here. The compare gate's ignore-list — CSRF token metadata, the HMAC suffix on signed stream names, asset fingerprints — marks real services a production deployment would put back. On the measured GET endpoints, only cookie verification and logging would add per-request cost; CSRF validation runs on the non-GET requests this benchmark never issues.

And the 239 number wants precision, not a round headline. These are issues I authored that are now closed. The reciprocity ran both ways — I caught at least one regression, and several lowerer patterns changed on the Roundhouse side to stay inside what Spinel handles cleanly. The accurate claim is that calibrating against a real Rails-shaped program generated 239 closed issues of inference and codegen maturation; it is not that Matz fixed 239 bugs (and merged 26 pull requests) from me, and it is not that Spinel endorses anything downstream of the fixes.

Coda

In April I wrote that Spinel and Roundhouse had arrived at the same intermediate language from opposite directions, and that I was betting the gaps would close. I ended that post: "Two compilers. One subset. Discovered independently, named only now. Probably not the last convergence."

The convergence has a native binary at the end of it now. Two hundred thirty-nine closed issues bought the distance between a fixture that codegen-compiled-but-wouldn't-run and one that compiles, runs, and serves the same DOM the Rails original does — verified the same three ways every Roundhouse target is. The bet wasn't that this would be easy. It was that the shape was right. The shape was right.

What I didn't expect in April is how little of the work was about the subset and how much was about everything underneath it — dispatch, inference, the garbage collector under load. The line between Rails-without-metaprogramming and Ruby-Spinel-can-compile turned out to be the easy part to agree on. Making the compiler deliver on its own documented subset was the long tail, and it closed one minimal repro at a time, exactly the way Matz said it would.


Roundhouse is open source: dual-licensed MIT / Apache-2.0. Issues and discussion welcome.