Lobsters benchmark results

Run latest · 2026-10-12T09:10:12Z · 9be4046e8e7f · spinel 6766a151e

The performance numbers on this page are not trustworthy

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.

2 routes the AOT lane is known not to render

routewhycloses withsince
/hottestthis snapshot's Story#as_json serializes a nested record (`submitter_user: user`), which the render-json monomorphizer declines, so the emit answers this route with its html armnested-record encoding: Story's writer calling User's, where User#as_json is the attrs + super(only:) idiom with four row-dependent keys, a pluck, and keybase_signatures (an Array of Hashes, which needs its own encoding)2026-08-03
/u/:usernameMarkdowner.to_html is a raising facade -- the AOT tree ships no CommonMark renderera 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 gem2026-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.

Roundhouse YJIT 25/26 content parity Roundhouse no JIT 25/26 content parity Futamura YJIT 26/26 content parity 3.47× — Roundhouse YJIT vs Rails YJIT Roundhouse YJIT 4.24× over the 22 routes it renders correctly Roundhouse no JIT 4.90× over the 22 routes it renders correctly
Artifacts: matrix.json · env.json · spinel-tree.tgz · ruby-tree.tgz · rails/rails.json · rails-int/rails.json · ruby/summary.json · ruby-int/summary.json · futamura/rails.json
Methodology — ruby-bench's, deliberately. Every lane times under the ruby-bench harness rule: 15 full-sequence warmup iterations discarded from the stats, then more until at least the minimum timed iterations are recorded AND total wall clock — warmup included, as upstream counts it — reaches the minimum seconds. The reported figure is the average ms per iteration over the non-warmup iterations, the statistic ruby-bench itself prints; the median stays in each lane's JSON as a robustness cross-check. The stability and comparison numbers replay a frozen 106-visit sequence (versioned at 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.

1. Roundhouse vs Rails — runtime × JIT

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; the AOT lane did not run. The 2 no-JIT rows size the JIT's contribution and are not the headline.

3.47× faster than Rails — 149.4 ms/iter (avg) vs Rails' 519.1, both stacks CRuby with YJIT. The full field:

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.

Roundhouse YJIT149.4 ms3.47× vs Railsn=53Roundhouse no JIT202.0 ms4.34× vs Railsn=35Futamura YJIT515.6 ms1.01× vs Railsn=20Rails YJIT519.1 msn=20Rails no JIT876.4 msn=20
stackJITavg ms/itermin–maxCVdriftitersvs Railsvs Rails
(parity only)
RoundhouseYJIT149.40128.2–175.010.0%-1.0%533.47×4.24×
22 routes
Roundhouseno JIT202.05187.6–221.33.7%-0.3%354.34×4.90×
22 routes
FutamuraYJIT515.62503.2–538.71.6%-0.7%201.01×—
RailsYJIT519.09506.2–540.91.7%-1.3%201.00×—
Railsno JIT876.43860.4–889.10.8%-0.6%201.00×—
RoundhouseAOTNO DATA — failed at replay: lobsters-spinel-replay: build: spin build failed after 67.6s

Every lane queried SQLite 3.50.3 at runtime.

Two ratios, because one lane is not rendering what Rails renders. Roundhouse YJIT is at 25/26 content parity; over the 22 routes it renders equivalently it is 4.24× (108.0 ms/iter vs 458.1), against 3.47× over the whole sequence; Roundhouse no JIT is at 25/26 content parity; over the 22 routes it renders equivalently it is 4.90× (161.0 ms/iter vs 789.5), against 4.34× 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.

YJIT during the timed window
lanecode regioniseqs compiledinvalidationscode GCscompile time
Roundhouse YJIT2,973 KiB / 4,380 KiB+300+2.9 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.

Per-lane iteration stability (5 lanes)

Roundhouse YJIT — 152.1 ms/iter median over 53 iterations (15 warmup)

128152175iteration 1iteration 53

Roundhouse no JIT — 204.6 ms/iter median over 35 iterations (15 warmup)

188205221iteration 1iteration 35

Futamura YJIT — 513.6 ms/iter median over 20 iterations (15 warmup)

503514539iteration 1iteration 20

Rails YJIT — 517.2 ms/iter median over 20 iterations (15 warmup)

506517541iteration 1iteration 20

Rails no JIT — 877.1 ms/iter median over 20 iterations (15 warmup)

860877889iteration 1iteration 20

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-10-12T09:12:28Z · host showcase.party — one process per lane, JIT pinned per lane.

2. Per-endpoint breakdown

Where the overall 3.47× win comes from — each endpoint's median latency over every timed visit of the frozen sequence, Rails vs roundhouse. Bars are log-scaled; the ratio at the right is Rails ÷ roundhouse (green = roundhouse faster).

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.

railsroundhouse0110100ms/newest4.4×/threads4.2×/threads/:username4.0×/rss2.2×/comments4.1×/s/:story_id5.3×/top?length=1w1.6×/top?length=1d1.6×/top?length=1y1.6×/saved6.4×/settings5.6×/recent5.8×/u6.9×/replies/comments6.0×/replies/unread4.7×/upvoted/comments5.3×/upvoted/stories5.9×/active4.6×/replies/stories6.7×/hidden5.7×/replies5.4×/comments/:comment_id/reply5.3×/about5.2×
Per-endpoint table (23 endpoints)
endpointrailsrails no-JITroundhouseroundhouse no-JITratiovisits/iter
/newest14.4426.753.255.564.45×5
/threads14.3826.163.465.564.16×3
/threads/:username11.33
own 9.36
20.062.81
own 2.31
4.544.03×2
/rss10.8316.594.906.352.21×4
/comments8.6815.912.103.304.13×4
/s/:story_id6.3911.501.221.905.25×15
/top?length=1w4.526.232.853.141.59×2
/top?length=1d4.496.082.903.161.55×4
/top?length=1y4.335.982.642.881.64×2
/saved3.806.880.600.996.35×5
/settings3.295.520.59
own 0.48
0.965.57×3
/recent3.005.130.520.805.75×2
/u2.503.560.36
own 0.27
floor 0.24
0.536.94×15
/replies/comments2.133.220.36
own 0.32
0.546.01×3
/replies/unread2.083.380.440.664.72×7
/upvoted/comments2.083.580.40
own 0.35
0.575.25×3
/upvoted/stories2.083.630.35
own 0.30
0.565.89×6
/active2.083.800.450.694.64×6
/replies/stories2.083.210.310.506.72×2
/hidden2.053.730.36
own 0.31
0.555.68×4
/replies1.993.100.37
own 0.32
0.555.43×3
/comments/:comment_id/reply1.963.250.370.585.34×5
/about1.262.170.240.425.24×1

All lane columns are median ms per visit; ratio = rails ÷ roundhouse (the +YJIT pair). 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.

3. Memory footprint

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 no JIT is at 25/26 content parity; Roundhouse YJIT is at 25/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.

Roundhouse no JIT121 MB103 MB baseline + 18 MB growthRoundhouse YJIT141 MB104 MB baseline + 38 MB growthRails no JIT309 MB177 MB baseline + 132 MB growthRails YJIT344 MB183 MB baseline + 160 MB growthFutamura YJIT344 MB183 MB baseline + 161 MB growth
stackJITbaselinepeakgrowthvs Rails (memory)
Roundhouseno JIT103 MB121 MB+18 MB2.56×
RoundhouseYJIT104 MB141 MB+38 MB2.43×
Railsno JIT177 MB309 MB+132 MB1.00×
RailsYJIT183 MB344 MB+160 MB1.00×
FutamuraYJIT183 MB344 MB+161 MB1.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.

4. Coverage — HTTP 200 (26/26)

What the numbers above are numbers of: every distinct route the benchmark exercises returns 200 on the CRuby emit target — the timing sections measure a complete application, not a working subset. A failing cell here would invalidate that lane's rows above.

Status is a weak oracle, though — a 200 that renders half a page is still a 200 — so the bodies are also compared against Rails' as parsed documents. 25/26 routes render content equivalent to Rails. See the section below for what "equivalent" is defined to mean and what the remainder are.

Per-route table (26 routes)
routestatusbytes
/u200173,564
/active2002,495
/newest20061,293
/recent2002,722
/hottest declared gap20016,369
/rss20016,362
/privacy2002,408
/about2002,281
/settings20015,425
/top?length=1d2002,624
/top?length=1w2002,627
/top?length=1y2002,627
/hidden2002,550
/saved2005,091
/upvoted/stories2002,723
/comments20035,759
/upvoted/comments2002,773
/threads20057,950
/comments/6srqij/reply2002,646
/threads/michell_wiegand20057,959
/u/michell_wiegand declared gap2004,252
/replies2002,716
/replies/comments2002,712
/replies/stories2002,719
/replies/unread2002,756
/s/enr7ye2007,433

This table verifies parity — what each route answered and how much of it — not speed; the timing sections above are the performance numbers.

5. Content parity vs Rails — Roundhouse YJIT 25/26, Roundhouse no JIT 25/26, Futamura YJIT 26/26

25/26 routes render what Rails renders

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 (&#39; vs &apos;) — and blind to nothing else.

routediffering tokensfirst difference
rss25
- "Tue, 18 Jul 2023 05:29:46 -0500"
+ "Tue, 18 Jul 2023 10:29:46 +0000"

Each route's first differing hunk; - is Rails, + is the emit. Full hunks in ruby/content-parity.md.

Normalization ledger (what the comparison forgives, and how often)
rulekindapplicationswhy
csrf-tokenmask29per-session token; a fresh value on every boot
relative-timemask109"3 hours ago" is a function of when the replay ran — the COUNT is masked, the unit is not
clock-countdownmask1"stop accepting comments in N minutes" ticks between the two replays
timestamp-valuemask109title=/datetime= instants differ by the gap between replays — digits are masked, the FORMAT is not
asset-prefixknown46emit serves /assets/NAME where Rails serves /stylesheets/ and /javascripts/
request-schemeknown1the 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.

6. Environment & provenance

Run, stacks, sequence

Run

commandscripts/bench-lobsters rails rails-int ruby ruby-int spinel futamura --time 20 --src /home/rubys/ruby-bench/benchmarks/lobsters
source/home/rubys/ruby-bench/benchmarks/lobsters
commit9be4046e8e7f365dfc8cd44163d67f3e0bfa0c3a
fixture commitef59d05987f925b2c72f4ab719f648e6b260338c
captured2026-10-12T09:12:28Z
databasefile:lobsters_bench?mode=memory&cache=shared
story / commentenr7ye / 6srqij

Stacks

roundhouse rubyruby 4.0.5 (2026-05-20 revision 64336ffd0e) +YJIT +PRISM [x86_64-linux]
rails rubyruby 4.0.5 (2026-05-20 revision 64336ffd0e) +YJIT +PRISM [x86_64-linux]
rails8.1.1

AOT toolchain

spinel (AOT)spinel 2026.09.12+8465 (6766a151e) [gcc 13.3.0 (cc)]
C compilercc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0

Host

hostshowcase.party
cpuAMD Ryzen 5 3600 6-Core Processor
cores12
kernelLinux 6.8.0-139-generic

Sequence

visits/iter106
warmup15
roundhouse iters53
rails iters20

Generated 2026-10-12T09:12:29Z from summary.json.