Roundhouse benchmark results

Run bench · 45 cells × 3 runs · 5 endpoints × 9 targets
Methodology. Hetzner AX41-NVMe (Ryzen 5 3600, 6c/12t, 3.6 GHz base, boost disabled, governor=performance), 64 GB ECC DDR4, NVMe SSD, Ubuntu 24.04. Toolchains via mise — Ruby 4.0.2+YJIT, Rust 1.94, Go 1.24, Node 23, Crystal 1.16. Harness: scripts/bench defaults — workers=1, c=64, 3×20s runs per cell. Quiet machine throughout (Kamal services moved off bench port).

These numbers measure one specific Rails reference app — not arbitrary Rails workloads. See #16 for the fixture-fragility caveats.

1. Throughput across targets

Each endpoint is its own chart. Bars are log-scaled; raw req/sec is shown at the right.

/articles

1001,00010,000100,0001,000,000req/secrust38,007crystal19,217typescript8,438spinel7,464go7,343ruby4,858ruby-int3,725rails-int494rails484

/articles/1

1001,00010,000100,0001,000,000req/secrust67,690crystal25,828typescript9,786spinel8,579go7,641ruby5,223ruby-int4,114rails-int475rails473

/articles/new

1001,00010,000100,0001,000,000req/secrust97,083crystal48,983typescript19,189spinel12,093go10,088ruby7,018ruby-int5,562rails-int700rails699

/articles.json

1001,00010,000100,0001,000,000req/secrust62,048crystal25,236go16,543typescript10,396spinel10,199ruby5,341ruby-int4,204rails-int900rails877

/articles/1.json

1001,00010,000100,0001,000,000req/secrust142,821crystal49,037go29,349typescript15,604spinel15,231ruby7,116ruby-int5,595rails-int1,329rails1,290

2. Lowerer dividend (ruby vs rails)

Same Ruby interpreter, same YJIT, same Puma — the only variable is whether the framework runtime is Rails or Roundhouse-emitted. The multiplier above each pair is the lift from the lowerer pipeline.

02,0004,0006,0008,00010,0004,858484/articles10.0×5,223473/articles/111.0×7,018699/articles/new10.0×5,341877/articles.json6.1×7,1161,290/articles/1.json5.5×rubyrails

3. YJIT contribution

YJIT consistently helps Roundhouse-emitted Ruby (small, predictable call shapes). On Rails it's neutral-to-slightly-negative — the deep autoload + reflection chain blunts it.

02,0004,0006,0008,00010,0004,8583,725484494/articles5,2234,114473475/articles/17,0185,562699700/articles/new5,3414,204877900/articles.json7,1165,5951,2901,329/articles/1.jsonrubyruby-intrailsrails-int

4. Cost economics (req/sec per GB of RSS)

Throughput normalized by memory footprint. Reorders the table — rust and crystal pull further ahead; high-RSS targets (typescript, rails) move down. Applicability varies by deployment shape: matters most for metered/serverless surfaces, least for bare-metal-with-headroom.

/articles

1,00010,000100,0001,000,00010,000,000req/sec/GBrust2,226,960crystal735,079go229,661spinel225,597ruby42,526ruby-int36,130typescript23,510rails-int1,673rails1,546

/articles/1

1,00010,000100,0001,000,00010,000,000req/sec/GBrust3,820,963crystal1,042,069spinel237,809go231,869ruby41,313ruby-int36,188typescript27,514rails-int1,548rails1,448

/articles/new

1,00010,000100,0001,000,00010,000,000req/sec/GBrust5,420,618crystal2,374,372spinel319,029go304,391typescript53,936ruby52,055ruby-int44,897rails-int2,186rails2,111

/articles.json

1,00010,000100,0001,000,00010,000,000req/sec/GBrust3,426,492crystal1,205,375go471,170spinel266,405ruby39,688ruby-int33,713typescript29,108rails-int2,758rails2,598

/articles/1.json

1,00010,000100,0001,000,00010,000,000req/sec/GBrust7,804,829crystal2,339,387go846,039spinel366,123ruby48,536typescript43,686ruby-int40,827rails-int3,545rails3,354

5. HTML → JSON multiplier

Per-target lift going from /articles (HTML) to /articles.json (JSON). The asymmetry isn't JSON-escape vs HTML-escape — both paths concatenate and escape strings. What JSON skips is the layout chain (csrf_meta_tag, importmap, asset-manifest scan, content_for slots) and the per-row view helpers (link_to, button_to, dom_id, tag.*) that the HTML index template invokes for every record.

Interpreter-bound targets (ruby, rails) get only 1.5–1.8× because per-call dispatch overhead dominates whether the work is helpers or serialization. Compiled targets show the helper-chain cost more clearly because their serialization is fast — Go's gap is the largest of the compiled set even after the IR string-builder annotation pass closed part of it.

rust1.6×crystal1.3×go2.3×spinel1.4×typescript1.2×ruby1.1×rails1.8×

6. Memory footprint (RSS)

Max RSS observed across all endpoints, per target. Rust and crystal sit under 30 MB; ruby and go are an order of magnitude higher; rails and typescript carry roughly another order on top of that. The cost-economics chart above (section 4) compounds these into req/sec/GB, which spans three orders of magnitude end-to-end.

rust18 MBcrystal26 MBgo35 MBspinel42 MBruby-int140 MBruby150 MBtypescript367 MBrails-int383 MBrails393 MB

7. Latency (p50 / p99 at c=64)

Latencies are c=64 concurrent connections; treat p50 as median per-connection wait, p99 as tail. Only rust hits sub-millisecond on any endpoint; crystal lands 1–4 ms, go and typescript 3–17 ms, ruby 9–22 ms, rails 45–135 ms. The order matches the throughput chart inversely, as expected.

target/articles/articles/1/articles/new/articles.json/articles/1.json
p50p99p50p99p50p99p50p99p50p99
rust1.73.10.92.20.61.51.01.90.41.0
crystal3.44.12.33.21.21.62.43.31.21.7
go8.321.68.018.53.245.63.69.91.98.1
spinel7.319.95.621.53.520.84.322.12.822.5
typescript7.48.76.47.73.33.66.07.33.94.8
ruby13.115.612.214.29.011.411.814.98.911.5
ruby-int17.120.615.518.011.413.715.118.011.413.5
rails127.1238.0130.6373.988.9160.472.893.247.069.8
rails-int125.2222.4130.6265.888.6163.169.887.545.4135.0

All values in milliseconds. Lower is better.

Raw cell data (45 rows)
targetendpointreq/secp50 (ms)p99 (ms)RSS (MB)req/sec/GB
rust/articles38,0071.663.07172,226,960
rust/articles/167,6900.872.19183,820,963
rust/articles/new97,0830.581.51185,420,618
rust/articles.json62,0481.011.88183,426,492
rust/articles/1.json142,8210.371.01187,804,829
crystal/articles19,2173.424.1026735,079
crystal/articles/125,8282.333.15251,042,069
crystal/articles/new48,9831.201.62212,374,372
crystal/articles.json25,2362.423.26211,205,375
crystal/articles/1.json49,0371.251.67212,339,387
go/articles7,3438.2721.6432229,661
go/articles/17,6417.9818.4833231,869
go/articles/new10,0883.1845.6533304,391
go/articles.json16,5433.599.8735471,170
go/articles/1.json29,3491.928.0935846,039
spinel/articles7,4647.3319.8733225,597
spinel/articles/18,5795.6221.5236237,809
spinel/articles/new12,0933.5220.7938319,029
spinel/articles.json10,1994.2622.1239266,405
spinel/articles/1.json15,2312.8522.5142366,123
typescript/articles8,4387.448.7236723,510
typescript/articles/19,7866.407.6836427,514
typescript/articles/new19,1893.313.6436453,936
typescript/articles.json10,3966.027.3036529,108
typescript/articles/1.json15,6043.884.8436543,686
ruby/articles4,85813.0915.5911642,526
ruby/articles/15,22312.2114.1512941,313
ruby/articles/new7,0189.0411.4513852,055
ruby/articles.json5,34111.8214.8713739,688
ruby/articles/1.json7,1168.8611.4715048,536
ruby-int/articles3,72517.1020.6410536,130
ruby-int/articles/14,11415.5118.0311636,188
ruby-int/articles/new5,56211.4513.7212644,897
ruby-int/articles.json4,20415.1217.9912733,713
ruby-int/articles/1.json5,59511.4113.5314040,827
rails/articles484127.07238.033201,546
rails/articles/1473130.62373.883341,448
rails/articles/new69988.87160.443392,111
rails/articles.json87772.8093.203452,598
rails/articles/1.json1,29046.9569.813933,354
rails-int/articles494125.22222.383021,673
rails-int/articles/1475130.60265.843141,548
rails-int/articles/new70088.63163.123282,186
rails-int/articles.json90069.8487.463342,758
rails-int/articles/1.json1,32945.44134.973833,545

Generated 2026-06-03T13:36:05Z from summary.json.