Timings below are still shown, and are still real measurements of what the code did — but what the code did was not what Rails does, so they do not measure the same work and must not be compared. This banner is generated from the run's own data and disappears by itself once every lane renders what Rails renders on every route IN SCOPE, and every timed visit returns 200. Routes deferred out of scope are listed below with the reason; they are excluded from the timed sequence in every lane, including Rails, so the lanes still measure the same work.
| route | why | closes with | since |
|---|---|---|---|
/hottest | inline `render json:` is not lowered for strict targets, so the emit answers this route with its html arm | the render-json monomorphizer: src/lower/as_json_shape.rs and as_json_writer.rs are committed; remaining is type inference for Computed pair values, nested-record and Array[String] encoding, the datetime offset format, and widening FormatBreadth | 2026-08-03 |
/u/:username | Markdowner.to_html is a raising facade -- the AOT tree ships no CommonMark renderer | a Commonmarker/Markly facade over an iterative Node#walk plus the DOM surgery Markdowner does (h1-h6 to strong, images to links, rel=ugc, @mention linkifier); scripts/markly-conformance already carries vectors generated from the real gem | 2026-08-03 |
These are counted as failures in every number on this page — the parity badge above still reads them as not rendering what Rails renders. Listing them here separates a capability we have not built from a difference nobody has explained; both are failures, and only one is a surprise.
bench/lobsters/frozen-sequence.json) so every run — and the Rails baseline — walks the identical path. Both stacks serve from an in-memory SQLite seeded at boot (file:lobsters_bench?mode=memory&cache=shared), the shape the ruby-bench lobsters benchmark runs. The per-route table further down replays each distinct route once, before warmup, to verify status, bytes and content — it publishes no latency; each lane's pre-warmup single shot stays in its summary JSON as a diagnostic.
The runtime × JIT matrix section re-runs the same frozen sequence once per interpreter × JIT lane — in-process, in-memory, single-threaded — so its rows differ only in the runtime under test.
Every lane runs ANALYZE once after seeding its in-memory copy (a departure from stock ruby-bench, whose fixture DB carries no sqlite_stat1): without planner statistics SQLite misplans the hottest-stories SELECT under /rss, /hottest, and /recent — a full-table walk plus sort instead of reading hotness_idx to the LIMIT, ~100× the query cost — and both stacks pay that same planner accident. Statistics restore the plan a production database would have; both lanes get identical stats, so the comparison is unchanged in kind and fairer in degree.
This measures one specific reference app (the ruby-bench lobsters fixture), not arbitrary Rails workloads. The Rails baseline runs the real Rails lobsters over the same sequence; the roundhouse cell serves the same routes from the Roundhouse-emitted framework.
5 lanes, three of them the claim. Rails YJIT is the baseline; Roundhouse YJIT is the like-for-like comparison against it — same runtime, same JIT, so the only variable is the code; Roundhouse AOT is the same emit through a different runtime, and is not at full content parity. The 2 no-JIT rows size the JIT's contribution and are not the headline.
3.65× faster than Rails — 124.5 ms/iter (avg) vs Rails' 454.1, both stacks CRuby with YJIT. The full field:
These ratios depend on run length. Roundhouse no JIT slowed 9.0% across its own timed run, so its ms/iter — and every multiplier drawn from it — would read differently at a different --time. Upward drift on the AOT lane is live-heap growth raising mark cost; downward drift on a JIT lane is warmup that has not finished.
Every lane replays the same frozen sequence in-process, in-memory, single-threaded — no HTTP server, no load generator — so the only variable across rows is the interpreter × JIT under test (the ruby-bench lobsters-for-YJIT shape). Lower is better; whiskers span each lane's min–max.
| stack | JIT | avg ms/iter | min–max | CV | drift | iters | vs Rails | vs Rails (parity only) |
|---|---|---|---|---|---|---|---|---|
| Roundhouse | AOT | 93.79 | 91.9–98.7 | 1.4% | +3.2% | 92 | 4.84× | 4.75× 21 routes |
| Roundhouse | YJIT | 124.48 | 115.2–150.9 | 7.6% | -1.9% | 64 | 3.65× | — |
| Roundhouse | no JIT | 167.22 | 155.4–185.5 | 5.7% | +9.0% | 46 | 4.51× | — |
| Rails | YJIT | 454.12 | 442.2–474.1 | 1.8% | -2.6% | 20 | 1.00× | — |
| Rails | no JIT | 754.65 | 738.8–773.6 | 1.0% | -0.4% | 20 | 1.00× | — |
Every lane queried SQLite 3.50.3 at runtime.
The AOT lane marks generationally — spinel's default collector since d3b1400d, attested by the binary's own counters rather than by what the harness exported. Roundhouse AOT: 6224 collections in the timed loop, 741 of them whole-heap.
Two ratios, because one lane is not rendering what Rails renders. Roundhouse AOT is at 22/26 content parity; over the 21 routes it renders equivalently it is 4.75× (88.9 ms/iter vs 421.9), against 4.84× over the whole sequence. Both sides of the restricted division cover the same routes. The unrestricted number is not wrong, but part of it is earned by rendering less.
Drifting lane: Roundhouse no JIT slowed 9.0% between the first and last quarter of its own timed iterations. A drifting lane's median depends on how long the run was, so its ratio is not a stable number — read it as a range, not a point. Lanes that hold steady drift under 1%.
| lane | code region | iseqs compiled | invalidations | code GCs | compile time |
|---|---|---|---|---|---|
| Roundhouse YJIT | 2,763 KiB / 3,900 KiB | +2 | 0 | 0 | +2.6 ms |
Deltas across the timed iterations only — warmup is excluded, so a zero row means YJIT had finished its work before the clock started. Non-zero iseqs compiled means new code was still being generated while being measured; invalidations means compiled code was being thrown away and redone; a non-zero code GCs means the code region filled and YJIT reclaimed it, after which previously-compiled paths must be compiled again.
“vs Rails” is a speed ratio — Rails' ms/iter ÷ this row's, at the same JIT level — so 3× means three times faster. Memory is a separate section below. CV = per-lane standard deviation ÷ mean across the timed iterations.
Each column is one timed iteration, scaled within a tight band around the lane's spread; the dashed line is the median.
Lanes measured 2026-08-26T07:01:28Z · host showcase.party — one process per lane, JIT pinned per lane.
Where the overall 3.65× win comes from — each endpoint's median latency over every timed visit of the frozen sequence, per lane. Bars are log-scaled; the ratio at the right is Rails ÷ roundhouse (both CRuby +YJIT) (green = roundhouse faster). The chart draws rails, roundhouse, roundhouse AOT; the no-JIT lanes appear as extra columns in the table below.
Both headline ratios include the transpile. Isolating the two: transpiling accounts for the bulk of the win, and AOT compilation adds a further 1.33× over the same emit running on CRuby+YJIT — in aggregate only, since AOT is faster on the heavy routes and slower on the cheap ones, where per-request fixed costs dominate.
A diagnostic breakdown, not a re-derivation: per-endpoint medians don't sum to the sequence median (GC and ordering don't decompose linearly), and the sub-millisecond rows sit near clock resolution — trust the ranking, not the third decimal.
Read the AOT bars against 22/26 content parity, not in isolation. 4 of its routes render something other than what Rails renders (worst by differing tokens: hottest, u_michell_wiegand, recent, active), and a route that renders less is faster for a reason that is not speed. The lanes drawn beside it are at full parity. Per-route detail in the content-parity section below.
| endpoint | rails | rails no-JIT | roundhouse | roundhouse no-JIT | AOT | ratio | visits/iter |
|---|---|---|---|---|---|---|---|
/threads | 12.69 | 22.44 | 3.42 | 5.18 | 2.49 | 3.72× | 3 |
/newest | 12.67 | 22.79 | 3.04 | 4.82 | 3.37 | 4.17× | 5 |
/threads/:username | 9.89 own 8.26 | 17.15 | 2.89 own 2.25 | 4.29 | 2.04 own 1.57 | 3.42× | 2 |
/rss | 9.41 | 14.21 | 4.64 | 5.93 | 2.06 | 2.03× | 4 |
/comments | 7.59 | 13.62 | 2.60 | 3.66 | 1.90 | 2.92× | 4 |
/s/:story_id | 5.56 | 9.90 | 0.96 | 1.46 | 0.92 | 5.80× | 15 |
/top?length=1w | 3.98 | 5.45 | 2.35 | 2.48 | 2.27 | 1.69× | 2 |
/top?length=1d | 3.95 | 5.28 | 2.34 | 2.44 | 2.21 | 1.69× | 4 |
/top?length=1y | 3.76 | 5.20 | 2.17 | 2.26 | 2.37 own 2.06 | 1.74× | 2 |
/saved | 3.39 | 5.95 | 0.36 | 0.62 | 0.46 own 0.26 | 9.28× | 5 |
/settings | 2.88 | 4.82 | 0.20 | 0.31 | 0.16 | 14.31× | 3 |
/recent | 2.66 | 4.37 | 3.64 | 3.76 | 0.06 own 0.04 | 0.73× | 2 |
/u cached | 2.16 | 3.08 | 0.18 | 0.29 | 0.24 own 0.17 floor 0.09 | 12.15× | 15 |
/replies/comments | 1.88 | 2.80 | 0.17 | 0.27 | 0.23 own 0.12 floor 0.11 | 10.93× | 3 |
/replies/unread | 1.85 | 2.98 | 0.17 | 0.27 | 0.20 own 0.11 floor 0.10 | 10.70× | 7 |
/active | 1.82 | 3.35 | 0.29 | 0.42 | 0.06 | 6.33× | 6 |
/upvoted/comments | 1.81 | 3.12 | 0.22 | 0.33 | 0.16 | 8.11× | 3 |
/upvoted/stories | 1.80 | 3.14 | 0.18 | 0.29 | 0.12 | 10.10× | 6 |
/replies/stories | 1.79 | 2.75 | 0.16 | 0.26 | 0.17 own 0.11 | 10.87× | 2 |
/hidden | 1.79 | 3.20 | 0.18 | 0.30 | 0.16 own 0.12 | 9.89× | 4 |
/replies | 1.70 | 2.68 | 0.17 | 0.28 | 0.22 own 0.12 floor 0.10 | 9.91× | 3 |
/comments/:comment_id/reply | 1.66 | 2.80 | 0.23 | 0.35 | 0.25 own 0.23 floor 0.20 | 7.19× | 5 |
/about | 1.09 | 1.87 | 0.15 | 0.24 | 0.09 | 7.19× | 1 |
AOT is slower than the same emit on CRuby+YJIT on 9 of 23 endpoints by median, but only 3 of 23 on own work. The difference is collection arrival, not code — /u 1.38× → 0.59×, /replies/comments 1.34× → 0.69×, /replies 1.31× → 0.67×, /saved 1.26× → 0.72×, /replies/unread 1.18× → 0.65×, and 2 more lose on the median and win on the floor. Routes that lose on both are the ones where the emitted code is genuinely behind.
All lane columns are median ms per visit; ratio = rails ÷ roundhouse (the +YJIT pair). The AOT column is the spinel-compiled binary — same emit, different runtime, and not at full content parity (see above). A route marked cached serves its timed visits from a cache in the app itself (lobsters keeps some whole pages for 24h), in every lane — those medians price a cache hit, not the render. An own figure appears where a route's median runs more than 10% above its own work (p25 of its visits) — the gap is collection triggered during that visit but caused by the heap the whole sequence built, so it belongs to the lane rather than to the route. It is largest on the cheapest routes, which is where the median is least trustworthy as a per-route cost. A floor figure appears below that where p25 is itself more than 10% above the fastest visit recorded: p25 only removes collection cost when fewer than a quarter of a route's visits collect, and where more than a quarter do, p25 is still pricing marks. The floor is one sample and therefore noisy downward — read it as a bound, not a measurement.
Peak resident memory of each lane's replay process — lower is better. Because the lanes replay in-process, each one measures itself (VmHWM from /proc/self/status), so this is the whole stack: interpreter, framework, the in-memory fixture DB, and whatever the timed run retains on top.
The bar splits at the point the sequence starts. The solid segment is the baseline — everything loaded and seeded, before a single visit. The translucent segment is growth across the timed run. The two say different things: baseline is what the stack costs to stand up, growth is what serving retains.
Roundhouse AOT is at 22/26 content parity. Peak memory is measured over the same sequence as the timings, so a lane that renders less also allocates and retains less — read its bar the way you read its ms/iter, not as a like-for-like footprint.
| stack | JIT | baseline | peak | growth | vs Rails (memory) |
|---|---|---|---|---|---|
| Roundhouse | AOT | 51 MB | 73 MB | +22 MB | — |
| Roundhouse | no JIT | 95 MB | 116 MB | +21 MB | 2.67× |
| Roundhouse | YJIT | 95 MB | 136 MB | +40 MB | 2.55× |
| Rails | no JIT | 177 MB | 309 MB | +132 MB | 1.00× |
| Rails | YJIT | 184 MB | 346 MB | +161 MB | 1.00× |
“vs Rails (memory)” is peak RSS ÷ peak RSS at the same JIT level — not the speed ratio in the matrix table above; the two differ. Baseline is sampled after the in-memory fixture DB is seeded, and it is not a common floor across lanes — each stack loads its own framework, so subtracting one lane's baseline from another's peak is meaningless.
Incomplete coverage: 3 route(s) fail on the spinel AOT lane — the failing routes' visits short-circuit cheaply, so treat the affected lane's timing rows above as optimistic. Details in the table.
| route | status | bytes | AOT | AOT bytes |
|---|---|---|---|---|
/u cached | 200 | 173,521 | 200 | 173,521 |
/active | 200 | 2,452 | 500 | 50 |
/newest | 200 | 61,250 | 200 | 61,250 |
/recent | 200 | 2,679 | 500 | 50 |
/hottest declared gap | 200 | 16,369 | 200 | 61,296 |
/rss | 200 | 16,362 | 200 | 16,362 |
/privacy | 200 | 2,365 | 200 | 2,365 |
/about | 200 | 2,238 | 200 | 2,238 |
/settings | 200 | 15,167 | 200 | 15,167 |
/top?length=1d | 200 | 2,581 | 200 | 2,581 |
/top?length=1w | 200 | 2,584 | 200 | 2,584 |
/top?length=1y | 200 | 2,584 | 200 | 2,584 |
/hidden | 200 | 2,507 | 200 | 2,507 |
/saved | 200 | 5,048 | 200 | 5,048 |
/upvoted/stories | 200 | 2,680 | 200 | 2,680 |
/comments | 200 | 35,716 | 200 | 35,716 |
/upvoted/comments | 200 | 2,730 | 200 | 2,730 |
/threads | 200 | 57,907 | 200 | 57,907 |
/comments/6srqij/reply | 200 | 2,603 | 200 | 2,603 |
/threads/michell_wiegand | 200 | 57,916 | 200 | 57,916 |
/u/michell_wiegand declared gap | 200 | 4,209 | 500 | 117 |
/replies | 200 | 2,673 | 200 | 2,673 |
/replies/comments | 200 | 2,669 | 200 | 2,669 |
/replies/stories | 200 | 2,676 | 200 | 2,676 |
/replies/unread | 200 | 2,713 | 200 | 2,713 |
/s/enr7ye | 200 | 7,390 | 200 | 7,390 |
This table verifies parity — what each route answered and how much of it — not speed; the timing sections above are the performance numbers. A route marked cached serves its timed visits from a cache in the app itself (lobsters keeps some whole pages for 24h), in every lane. An AOT byte count is flagged when it falls more than 10% short of the CRuby lane's — a 200 that renders a fraction of the page.
Why they fail — grouped by reason, because a shared cause is one fix:
| lane | route(s) | reason |
|---|---|---|
| AOT | /active, /recent | TypeError: can't convert nil into Integer |
| AOT | /u/michell_wiegand | NotImplementedError: gem facade: Markdowner.to_html is stubbed (write-path only; see runtime/gem_facades.rb) |
Every parity route is fetched from real Rails and from the emit, and both bodies are parsed into a canonical token stream and diffed there. That makes the comparison blind to four things that are not differences — indentation and formatting whitespace between block elements, attribute order, void-element spelling (<img> vs <img />), and character-reference spelling (' vs ') — and blind to nothing else.
| rule | kind | applications | why |
|---|---|---|---|
csrf-token | mask | 29 | per-session token; a fresh value on every boot |
relative-time | mask | 109 | "3 hours ago" is a function of when the replay ran — the COUNT is masked, the unit is not |
clock-countdown | mask | 1 | "stop accepting comments in N minutes" ticks between the two replays |
timestamp-value | mask | 109 | title=/datetime= instants differ by the gap between replays — digits are masked, the FORMAT is not |
asset-prefix | known | 46 | emit serves /assets/NAME where Rails serves /stylesheets/ and /javascripts/ |
request-scheme | known | 1 | the Rails replay builds https:// envs, the emit replay http:// — a harness artifact, not an app difference |
mask — varies per run or per session by construction and carries no information. known — a genuine, understood difference from Rails, folded so it does not drown the signal but counted here so it cannot be forgotten.
Compared against the same Rails oracle, by the same token-stream rules. Ordered by size of difference.
| route | differing tokens | first difference |
|---|---|---|
recent | 138 | - <!doctype html> - <html class="color-scheme-system" lang="en"> - <head> + "BENCHERR TypeError: can't convert nil into Integer" |
active | 133 | - <!doctype html> - <html class="color-scheme-system" lang="en"> - <head> + "BENCHERR TypeError: can't convert nil into Integer" |
- is Rails, + is the emit. Full hunks in spinel/content-parity.md.
| rule | kind | applications |
|---|---|---|
csrf-token | mask | 30 |
relative-time | mask | 122 |
clock-countdown | mask | 1 |
timestamp-value | mask | 122 |
asset-prefix | known | 44 |
request-scheme | known | 1 |
| command | scripts/bench-lobsters rails rails-int ruby ruby-int spinel --time 20 --src /home/rubys/ruby-bench/benchmarks/lobsters |
|---|---|
| source | /home/rubys/ruby-bench/benchmarks/lobsters |
| commit | ec8572016b1a7cbbe96b3c3ce6f10ce4c6d80c4c |
| fixture commit | 09b76c8b0f94c8182080cdae01e95151f0e22dd9 |
| captured | 2026-08-26T07:01:28Z |
| database | file:lobsters_bench?mode=memory&cache=shared |
| story / comment | enr7ye / 6srqij |
| roundhouse ruby | ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +YJIT +PRISM [x86_64-linux] |
|---|---|
| rails ruby | ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +YJIT +PRISM [x86_64-linux] |
| rails | 8.1.1 |
| spinel (AOT) | spinel 5e040768f582 [cc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0] |
|---|---|
| C compiler | cc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0 |
| AOT compile | 226.7 s |
| binary | 9.2 MiB |
| C warnings | 0 |
| spinel warnings | 23 |
| host | showcase.party |
|---|---|
| cpu | AMD Ryzen 5 3600 6-Core Processor |
| cores | 12 |
| kernel | Linux 6.8.0-137-generic |
| visits/iter | 106 |
|---|---|
| warmup | 15 |
| roundhouse iters | 64 |
| rails iters | 20 |