A Bigger Fixture
Every performance number in this series so far has come from a single application: the quintessential Rails blog demo, five endpoints, modernized with Turbo and Tailwind. It was chosen deliberately — small enough that I could hold every emitted line in my head, and hold Roundhouse's output to Rails' byte for byte through a compare gate. But a five-endpoint blog is exactly the application most likely to flatter a compiler, and I've said as much every time: your application almost certainly does not transpile today, and one fixture is one fixture.
So this post is about the second fixture, roughly ten times the size, and — the part I care about — not mine. lobste.rs is a real, deployed, community-run Rails application: nested comment threading, a hotness-ranked front page, per-user tag filters, a moderation log, saved and upvoted and hidden stories, a settings page with two dozen typed preferences. And it comes with a benchmark I didn't write. The ruby-bench project uses lobste.rs as one of its yardsticks for YJIT: a frozen, seeded sequence of 114 authenticated page visits, replayed against a 42MB snapshot of production data — about 11,000 stories, 26,000 comments, 1,000 users, and a SQL view — with an assertion that every single visit returns HTTP 200. That last clause is the whole point. You cannot pass this benchmark by rendering something; you pass it by rendering the page the request asked for, logged in, from real data, 114 times without a miss.
The headline, stated plainly
Roundhouse ingests lobste.rs, type-checks it, and emits it as standalone, metaprogramming-free Ruby — the same Ruby target the blog uses. That emitted application now runs the ruby-bench sequence to completion:
- All 114 visits return 200, across 26 distinct routes, logged in — home in three orderings, the comment and reply trees, the user profile tree, RSS and JSON formats, the settings form.
- Output structurally matches Rails route by route: the
/uinvitation tree renders the same 1,009 list items both sides; the comment trees have the same depth and count;/hottestreturns JSON that parses byte-identical to Rails';/upvotedis correctly empty for the benchmark user, because the vote-scoped association actually filters. - 3.03× Rails' throughput on the same machine: 298 ms per iteration versus Rails' 903, on a Hetzner x86_64 box, Rails 8.1.1 and the emit both on the same Ruby. The full report — the frozen sequence, the per-route timings, the provenance — is published and regenerates on every benchmark cycle.
This is the same clean experiment the blog runs, minus the theatrics: one source application, two codebases (Rails, and Roundhouse's emit of it), one runtime. What changed is that the source application is now one somebody else maintains, measured by a benchmark somebody else designed.
It came together fast — a couple of days from serves individual routes to serves the whole benchmark with numbers — but only because the slow part was already done. The months of analyze-stage work driving lobste.rs's diagnostics toward zero is what made the emit possible at all; the benchmark harness was the visible tip of it.
The honest reading of 3.03×
The blog serves its HTML index 11× faster than Rails under the same runtime, and its JSON endpoint 6×. Lobste.rs, a far more serious application, comes in at 3×. A skeptic's first instinct is that the bigger app exposes the smaller app's number as inflated. That instinct is worth taking seriously, and it's wrong — but it's wrong in a way that's more interesting than a flat denial.
Look at the spread across routes in the published run:
| route | Rails (ms) | Roundhouse (ms) | ratio |
|---|---|---|---|
/settings |
4.80 | 0.43 | 11.2× |
/hidden |
3.22 | 0.34 | 9.4× |
/saved |
5.94 | 0.70 | 8.5× |
/s/:story |
9.89 | 1.78 | 5.6× |
/newest |
22.74 | 4.84 | 4.7× |
/comments |
— | — | 1.02× |
The multiple is not a property of the framework. It's a property of the route — of how much of its time goes to the request-invariant machinery compilation deletes, versus the per-row work that scales with data. /settings renders a form over a handful of columns; almost all of its Rails time is interpretive machinery — route recognition, association resolution, template lookup, type-casting — that Roundhouse decides once at transpile time and deletes from every request. Strip that and there's nearly nothing left, so the emit is 11× faster. /comments, at the other end, renders a deep comment tree over thousands of rows; most of its time goes per row, and both stacks pay that. Remove the interpretive overhead from both and the ratio compresses toward parity.
The tempting way to summarize that is Amdahl's law: the compilable fraction is small on a data-heavy app, so the multiple is small, and the rest is irreducible database work. The first half is true. The second half — the rest is the database — I believed until I profiled it, and it is wrong, in a way that matters enough to correct in public. At the published commit, actual SQL execution — SQLite's step — is 8.8% of wall time. What I had lazily filed under "the database" decomposes into machinery, not physics:
| band | share of wall |
|---|---|
| per-row hydration — object init, writer copies, typed cast, hash build | ~22% |
| string / escape C-calls — gsub-based HTML escaping, tag parameterization, URL decode | 27.8% |
| garbage collection — mostly hydration allocations | 14.6% |
| dispatch and framework | 13.5% |
SQLite proper (step) |
8.8% |
Only that last row is genuinely irreducible. The 22% hydration band is the emitted runtime copying each database row three times on its way to becoming a model object — a name-keyed hash, then a typed cast, then a Model.new that writes two dozen attribute defaults and initializes fifteen association caches before the real values overwrite them. That is not the database. It is request-invariant ceremony, and it is exactly the kind of thing this project exists to compile away: issue #65 is the filed plan to replace all three copies with a positional hydrator that allocates the object and fills it column by column. Since the GC band is mostly those same allocations, it moves with the hydrator; and even a good part of the 27.8% string band is escaping and URL-building the emit could specialize further.
So Amdahl is the wrong frame for the conclusion even where it's the right frame for the intuition. The genuinely irreducible fraction is under 9%, not 40%; the rest of the remaining time is addressable, with a named roadmap against it. 3× is where a data-heavy app sits today, with levers still open — not a wall that physics put there.
Which means the 3× was not handed over by the compiler, and it isn't capped by the database either. It was earned, and the earning is the part I'd want a fellow engineer to see. The first end-to-end comparison had the emit at 0.93× — slower than Rails — because Rails has a mature per-request query cache and my emitted runtime had none, so it re-ran queries Rails answered from memory. Getting to 3× was a sequence of making the emitted runtime as smart as Rails already is, and removing places where it was accidentally dumber:
- A real per-request query cache and an LRU prepared-statement cache, replacing a runtime that re-parsed the same SQL every time.
- Batched
includespreloading, turning an N+1 fan-out of ~2,000 queries per iteration into ~1,100 — within 45 queries of Rails' own count. - A
Model.lastthat had been quietly loading the entire users table into memory to take the last row, restored toORDER BY id DESC LIMIT 1. (This was the one endpoint that had been slower than Rails, and the heaviest-weighted route in the sequence. Finding it took per-endpoint attribution, not an aggregate — a lesson I keep re-learning.) - Timestamp parsing that had been running Ruby's fully general date parser on every hydrated column, replaced with a fixed-format fast path.
That last-but-one fix feeds directly back into the table above: the Model.last scan is why the hydration band read ~40% when #65 was first written and reads ~22% now — the phantom full-table load had been inflating the very profile the issue was arguing against. Two smaller allocation-hygiene fixes — memoizing the route table, swapping pure-Ruby escaping for C-accelerated CGI.escapeHTML — are already in the published commit. And the honest coda: at a sub-140-millisecond local iteration, several of the remaining levers now measure inside run-to-run noise, so whether the next one earns its complexity is a re-profile-first question rather than a foregone win. The only claim I'll stand on is that 3× has room above it, and the room is the compilable kind.
It composes with YJIT — it doesn't replace it
There's a temptation to read a number like this as Roundhouse instead of the Ruby runtime. That's the wrong mental model, and the benchmark is built to make the point. The emitted lobste.rs is just Ruby. It runs on the same MRI the Rails version runs on — and on that MRI, YJIT is on by default now; there's no flag to set and nothing to opt into, so both the Rails app and the emit are already getting it. Roundhouse isn't a faster runtime competing with YJIT; it's the same runtime, handed the kind of code YJIT was built to optimize — monomorphic call sites, resolved constants, direct string building — instead of the metaprogramming-dense code Rails necessarily hands it. The two effects stack: the JIT does its job on both codebases, and the emit is the codebase it can do the most with. This is the same composition the JRuby post documented on the blog — the JVM rewarded the lowered code more than it rewarded Rails — now visible on a real application, and with nothing to configure on either side.
The caveats, undiminished
Everything in Numbers Without Conclusions still applies, and lobste.rs adds a few of its own:
- Only the 26 benchmark routes work. This is not the whole application transpiled; it's the exercised surface transpiled. Routes outside the sequence — search, submission, the moderation actions — are very likely broken today. The benchmark defines the subset, exactly as the blog demo did, and the subset is what the numbers cover.
- This is a single-threaded, in-process replay, not concurrent HTTP serving. The blog benchmark runs 64 connections through Puma; the lobste.rs harness replays a frozen sequence in one process to make the two stacks bit-for-bit comparable. Under real concurrency Rails pays for GC and memory pressure in ways this measurement doesn't capture, so if anything this is the conservative framing — but it is a different measurement, and I won't claim the concurrent number until I've run it.
- The ratio is machine-dependent. 3.03× is the Hetzner x86_64 figure; the same commit runs a different multiple on my laptop, with the absolute milliseconds roughly 2× apart on both stacks. Quote paired runs from one machine; never mix.
- Byte-level residue remains. Structural and data parity hold — same rows, same order, same tree shapes,
/hottestbyte-identical — but some pages still differ in whitespace-trimming and a few cosmetic attributes that the blog's compare gate would flag. Closing that to blog-grade byte parity is in progress, tracked route by route. - This is the CRuby target only. Which is the natural segue.
What's next
Three directions, all already scaffolded elsewhere in this series or on the tracker.
Spinel. The Ruby target proves the shape of the emit; Spinel compiles that shape ahead of time, with no interpreter present at all. That's the differentiating number — not "Rails made faster" but "Rails with the interpreter removed from the deployment" — and lobste.rs is the completeness oracle for it: a whole reachable graph that either compiles or names precisely what doesn't. It's the same proving-lane logic as the Mastodon ladder, one rung down in size.
JRuby. The emitted lobste.rs is the same Ruby that ran 5–6× faster under the JVM JIT on the blog, for the same reason — monomorphic code is the input that JIT was built for. Whether the JVM pays that dividend as fully on a data-heavy application — where more of the time is per-row hydration, allocation, and string work than interpretive dispatch — is a genuinely open question, and exactly the kind I'd rather measure than assert.
More of the runtime, compiled. The profile earlier in this post is also a to-do list. The synthesized positional hydrator is the largest single coherent lever; the string/escape band and the GC that trails its allocations are the next two. None of that is framework interpretation and none of it is the database — it's runtime ceremony the emit hasn't specialized yet. The discipline the issue thread already imposes on itself is the one I'd keep: re-profile before starting, because the last round of fixes may have reordered the bands, and land the honest ones as hygiene rather than booking projected wins before they're measured.
And underneath all three: more routes, toward the whole application rather than the benchmarked subset — the same road the Mastodon goal runs down, at ten times the scale. Lobste.rs is the tenth-scale model that gets the emit path honest before the real thing depends on it.
See it yourself
The benchmark isn't a screenshot. The published report carries the 3.03× headline, the per-route breakdown, and the full provenance — the exact arguments, Ruby and Rails versions, and iteration counts of the run. The frozen 114-visit sequence is published alongside it, so you can see precisely which requests are measured rather than take "114 visits" on faith. And the transpiled application itself is browsable — explore its inferred types in the IDE, or watch it transpile in the Playground, both running in the browser with nothing installed. The lobste.rs landing page links all of it.
If you run lobste.rs, or you maintain the ruby-bench harness, and these numbers don't match what you'd expect — or they do — that's calibration data I want, and Discussions is the place.
Roundhouse is open source: dual-licensed MIT / Apache-2.0. Issues and discussion welcome.