Roundhouse benchmark results

Run bench · captured 2026-07-15T04:00:15Z · 150ad2ebe58c · 85 cells × 3 runs · 5 endpoints × 17 targets
Artifacts: env.json · per-run.json · summary.json · summary.md
Methodology. AMD Ryzen 5 3600 6-Core Processor, 6c/12t, 3.6 GHz, boost enabled, governor=schedutil, 63 GB RAM, Ubuntu 24.04.4 LTS. Toolchains pinned via mise. Harness: scripts/bench — workers=1, c=64, 3×20s runs per cell after 20s warmup; the median of the 3 runs is reported (the run-to-run spread is in the stability section below). Quiet machine throughout. The managed-heap cells run under a fixed heap budget so their RSS is comparable to the other cells' working sets rather than the runtime's default share of host RAM: the JVM cells (jruby, kotlin) via -Xmx512m=-Xms512m, and the .NET cells (csharp, csharp-aot) via the equivalent DOTNET_GCHeapHardLimit (512 MB). Full hardware, toolchain versions, and the exact invocation are in the environment appendix below.

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

rails-jruby is the stock-Rails-on-the-JVM baseline — the same blog, pinned to Rails 8.0 (one minor behind the others' 8.1) on a prerelease AR-JDBC adapter (activerecord-jdbcsqlite3-adapter 80.0.pre1), the only combination that drives ActiveRecord on JRuby 10 today.

spinel runs the same Roundhouse-emitted framework as the CRuby ruby cell, but on the Spinel VM behind the Tep fiber server rather than CRuby + Puma — so the rubyspinel gap is a runtime swap, not a framework or source change.

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/seckotlin55,727rust53,415csharp-aot42,019csharp39,476jruby23,441swift19,759crystal19,352typescript11,810spinel10,365go8,443elixir6,138python4,862ruby3,055ruby-int2,242rails-jruby1,131rails-int339rails325

/articles/1

1001,00010,000100,0001,000,000req/secrust77,315kotlin65,770csharp-aot53,018csharp52,601jruby25,557crystal25,460swift21,447typescript13,526elixir13,503spinel12,279go8,726python6,296ruby3,423ruby-int2,524rails-jruby1,039rails-int326rails319

/articles/new

1001,00010,000100,0001,000,000req/seckotlin137,259csharp-aot103,927rust103,735csharp93,708crystal51,329elixir45,250jruby38,022typescript26,164swift25,340spinel19,625go11,231python9,445ruby4,244ruby-int3,306rails-jruby1,694rails-int470rails470

/articles.json

1001,00010,000100,0001,000,000req/secrust84,629kotlin64,074csharp53,780csharp-aot53,365swift38,366jruby30,672crystal28,533go20,226elixir18,159spinel14,035typescript13,329python8,821ruby3,854ruby-int2,904rails-jruby882rails-int621rails588

/articles/1.json

1001,00010,000100,0001,000,000req/secrust153,344kotlin104,170csharp-aot100,466csharp93,026crystal53,235swift43,497jruby43,176go36,700elixir27,890spinel24,157typescript21,286python10,483ruby5,223ruby-int3,997rails-jruby1,169rails-int948rails902

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.

01,0002,0003,0004,0005,0006,0007,0003,055325/articles9.4×3,423319/articles/110.7×4,244470/articles/new9.0×3,854588/articles.json6.6×5,223902/articles/1.json5.8×rubyrails

The same comparison on the JVM: emitted jruby beats stock rails-jruby by 21× on /articles, against for ruby over rails on CRuby.

3. YJIT contribution

Each pair compares a cell with YJIT (ruby, rails) against its interpreter-only variant (ruby-int, rails-int). The bars show how far YJIT moves each.

01,0002,0003,0004,0005,0006,0007,0003,0552,242325339/articles3,4232,524319326/articles/14,2443,306470470/articles/new3,8542,904588621/articles.json5,2233,997902948/articles/1.jsonrubyruby-intrailsrails-int

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

Throughput normalized by memory footprint — req/sec divided by RSS in GB. This reorders the raw throughput charts above: targets with small working sets rise and high-RSS targets fall. Bars are log-scaled. How much the metric matters depends on the deployment shape — most on metered or serverless surfaces, least on bare metal with memory headroom.

/articles

1,00010,000100,0001,000,00010,000,000req/sec/GBrust3,126,283spinel882,266crystal808,100csharp-aot586,439swift444,431csharp330,083go257,006kotlin105,028python61,613elixir46,806typescript36,968jruby26,194ruby-int24,023ruby23,599rails-int1,173rails-jruby1,109rails1,079

/articles/1

1,00010,000100,0001,000,00010,000,000req/sec/GBrust4,403,186spinel1,051,651crystal1,020,382csharp-aot723,939swift465,592csharp426,148go252,491kotlin101,801elixir100,943python79,553typescript42,175jruby26,780ruby25,320ruby-int22,140rails-int1,090rails1,027rails-jruby911

/articles/new

1,00010,000100,0001,000,00010,000,000req/sec/GBrust5,635,999crystal2,358,567spinel1,696,252csharp-aot1,391,208csharp730,190swift524,333elixir339,168go324,526kotlin198,201python119,340typescript81,593jruby39,829ruby29,554ruby-int26,533rails-int1,560rails-jruby1,509rails1,464

/articles.json

1,00010,000100,0001,000,00010,000,000req/sec/GBrust4,618,044crystal1,332,358spinel1,155,556swift787,286csharp-aot719,436go555,126csharp431,736elixir133,189python111,460kotlin83,006typescript43,339jruby32,118ruby25,729ruby-int22,248rails-int2,007rails1,813rails-jruby752

/articles/1.json

1,00010,000100,0001,000,00010,000,000req/sec/GBrust8,279,799crystal2,436,349spinel1,910,900csharp-aot1,363,575go1,006,950swift876,993csharp745,066elixir207,166python132,465kotlin131,476typescript69,364jruby45,171ruby31,295ruby-int27,151rails-int2,708rails2,422rails-jruby995

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. The chart below shows the multiplier per target.

rails-jruby's multiplier falls below 1× (0.8×): its JSON endpoint measures slower than its HTML one.

elixir3.0×go2.4×swift1.9×python1.8×rails1.8×rust1.6×crystal1.5×csharp1.4×spinel1.4×jruby1.3×csharp-aot1.3×ruby1.3×kotlin1.1×typescript1.1×rails-jruby0.8×

6. Memory footprint (RSS)

Max RSS observed across all endpoints, per target, sorted low to high. The field spans more than two orders of magnitude. The managed-heap cells (the JVM and .NET targets) run under a fixed heap budget rather than an organically-grown working set — see the note below the chart.

spinel12 MBrust18 MBcrystal25 MBgo37 MBswift50 MBcsharp-aot76 MBpython81 MBcsharp131 MBelixir139 MBruby-int150 MBruby170 MBtypescript328 MBrails-int358 MBrails381 MBkotlin811 MBjruby978 MBrails-jruby1,202 MB

The JVM cells run under a fixed -Xmx512m heap, so the heap can't grab a host-dependent share of RAM; the rest is metaspace and code-cache, bounded by the app's class count rather than the box. Read the JVM bars as a pinned budget, not an organically-grown working set like the other cells.

The .NET cells are capped the same way — DOTNET_GCHeapHardLimit at 512 MB, the -Xmx analog — so their RSS is a budget the GC collects within, not the host-proportional reservation Server GC takes by default. NativeAOT (csharp-aot) carries no JIT or warmup, so its working set is the leanest of the managed-heap cells.

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

Latencies are at c=64 concurrent connections; treat p50 as the median per-connection wait and p99 as the tail. Rows are ordered by measured median p50 across endpoints, fastest first.

target/articles/articles/1/articles/new/articles.json/articles/1.json
p50p99p50p99p50p99p50p99p50p99
rust1.22.20.72.50.51.60.71.80.31.0
kotlin0.94.30.83.70.32.40.83.90.42.7
csharp1.34.81.04.20.43.41.04.40.53.1
csharp-aot1.36.21.05.50.43.81.05.20.53.1
jruby2.65.02.44.71.63.72.04.11.43.4
crystal3.33.92.43.11.21.62.22.91.21.6
swift3.06.02.76.32.28.31.62.91.24.8
go7.218.97.016.32.947.12.97.91.57.1
elixir10.315.84.68.51.24.23.56.82.24.5
typescript5.210.44.58.82.44.54.68.92.95.7
spinel6.16.95.16.23.23.74.65.22.63.4
python13.013.910.011.06.77.57.27.76.06.9
ruby20.725.819.024.915.119.016.521.912.315.7
ruby-int29.034.125.831.719.624.322.127.316.320.6
rails-jruby52.176.657.284.034.056.370.9102.052.076.0
rails189.5353.1194.0433.5132.3227.3108.9126.470.2214.2
rails-int183.2372.6190.3388.8132.6230.6102.7130.466.6167.0

All values in milliseconds. Lower is better.

Run-to-run stability

Each cell is timed 3 times and the charts above report the median run. This section reads per-run.json directly to show how far the individual runs strayed from it. Across all 85 cells the median run-to-run coefficient of variation in req/sec is 0.57% — the timed runs of a given cell agree to a fraction of a percent, so the reported medians aren't masking noise.

The 6 cells that vary by more than 3% are concentrated in the lowest-throughput cells, where a small absolute swing is a larger fraction of the rate; in 4 of them the first timed run is the slowest, consistent with residual warmup the fixed 20s warmup doesn't fully absorb:

targetendpointrun 1run 2run 3CV
rails-jruby/articles7141,1311,14420.0%
rails-jruby/articles/19061,0391,0396.3%
rails/articles/1.json8209119024.6%
ruby/articles/13,5463,2183,4234.0%
ruby-int/articles.json2,8002,9043,0433.4%
ruby-int/articles/new3,4413,1913,3063.1%

req/sec per timed run; CV = standard deviation ÷ mean.

Raw cell data (85 rows)
targetendpointreq/secp50 (ms)p99 (ms)RSS (MB)req/sec/GB
rust/articles53,4151.172.20173,126,283
rust/articles/177,3150.742.51174,403,186
rust/articles/new103,7350.471.62185,635,999
rust/articles.json84,6290.701.84184,618,044
rust/articles/1.json153,3440.341.01188,279,799
crystal/articles19,3523.303.9124808,100
crystal/articles/125,4602.423.14251,020,382
crystal/articles/new51,3291.151.59222,358,567
crystal/articles.json28,5332.152.93211,332,358
crystal/articles/1.json53,2351.151.56222,436,349
go/articles8,4437.1918.8933257,006
go/articles/18,7266.9916.2835252,491
go/articles/new11,2312.8847.0635324,526
go/articles.json20,2262.937.9337555,126
go/articles/1.json36,7001.547.12371,006,950
csharp-aot/articles42,0191.306.2273586,439
csharp-aot/articles/153,0181.035.5274723,939
csharp-aot/articles/new103,9270.443.83761,391,208
csharp-aot/articles.json53,3651.025.1875719,436
csharp-aot/articles/1.json100,4660.473.06751,363,575
kotlin/articles55,7270.934.33543105,028
kotlin/articles/165,7700.783.69661101,801
kotlin/articles/new137,2590.342.40709198,201
kotlin/articles.json64,0740.813.8979083,006
kotlin/articles/1.json104,1700.452.72811131,476
swift/articles19,7593.036.0045444,431
swift/articles/121,4472.726.3147465,592
swift/articles/new25,3402.198.3249524,333
swift/articles.json38,3661.562.9049787,286
swift/articles/1.json43,4971.164.8450876,993
csharp/articles39,4761.284.77122330,083
csharp/articles/152,6010.994.16126426,148
csharp/articles/new93,7080.453.40131730,190
csharp/articles.json53,7800.994.39127431,736
csharp/articles/1.json93,0260.493.13127745,066
spinel/articles10,3656.146.9112882,266
spinel/articles/112,2795.126.15111,051,651
spinel/articles/new19,6253.233.72111,696,252
spinel/articles.json14,0354.555.22121,155,556
spinel/articles/1.json24,1572.573.42121,910,900
elixir/articles6,13810.2915.7813446,806
elixir/articles/113,5034.658.46136100,943
elixir/articles/new45,2501.244.15136339,168
elixir/articles.json18,1593.466.81139133,189
elixir/articles/1.json27,8902.214.46137207,166
typescript/articles11,8105.2310.3832736,968
typescript/articles/113,5264.518.7832842,175
typescript/articles/new26,1642.384.4832881,593
typescript/articles.json13,3294.608.8631443,339
typescript/articles/1.json21,2862.895.6631469,364
python/articles4,86213.0413.948061,613
python/articles/16,29610.0511.038179,553
python/articles/new9,4456.677.5381119,340
python/articles.json8,8217.177.7281111,460
python/articles/1.json10,4836.026.9281132,465
ruby/articles3,05520.7325.8113223,599
ruby/articles/13,42318.9524.8913825,320
ruby/articles/new4,24415.0819.0114729,554
ruby/articles.json3,85416.5421.8615325,729
ruby/articles/1.json5,22312.2715.6817031,295
ruby-int/articles2,24228.9734.109524,023
ruby-int/articles/12,52425.8431.7311622,140
ruby-int/articles/new3,30619.5824.2712726,533
ruby-int/articles.json2,90422.1227.3413322,248
ruby-int/articles/1.json3,99716.2620.5615027,151
jruby/articles23,4412.614.9591626,194
jruby/articles/125,5572.414.6797726,780
jruby/articles/new38,0221.603.7497739,829
jruby/articles.json30,6722.014.1197732,118
jruby/articles/1.json43,1761.433.4297845,171
rails/articles325189.46353.073081,079
rails/articles/1319194.00433.523181,027
rails/articles/new470132.30227.293281,464
rails/articles.json588108.94126.423321,813
rails/articles/1.json90270.25214.253812,422
rails-int/articles339183.24372.612951,173
rails-int/articles/1326190.29388.853061,090
rails-int/articles/new470132.63230.613081,560
rails-int/articles.json621102.74130.413172,007
rails-int/articles/1.json94866.65166.983582,708
rails-jruby/articles1,13152.0876.601,0431,109
rails-jruby/articles/11,03957.1583.961,168911
rails-jruby/articles/new1,69434.0056.341,1481,509
rails-jruby/articles.json88270.91102.021,200752
rails-jruby/articles/1.json1,16951.9876.001,202995
Environment (captured 2026-07-15T04:00:15Z)

Host

hostnameshowcase.party
OSUbuntu 24.04.4 LTS
kernelLinux showcase.party 6.8.0-124-generic #124-Ubuntu SMP PREEMPT_DYNAMIC Tue May 26 13:00:45 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux
boardASRock B450 Pro4 R2.0
CPUAMD Ryzen 5 3600 6-Core Processor
topology6 cores / 12 threads
clockgovernor=schedutil, boost enabled, 3600 MHz max
memory64,221 MB

Toolchains

cargocargo 1.95.0 (f2d3ce0bd 2026-03-21)
crystalCrystal 1.20.2 [2482c62c1] (2026-05-15)
curlcurl 8.5.0 (x86_64-pc-linux-gnu) libcurl/8.5.0 OpenSSL/3.0.13 zlib/1.3 brotli/1.1.0 zstd/1.5.5 libidn2/2.3.7 libpsl/0.21.2 (+libidn2/2.3.7) libssh/0.10.6/openssl/zlib nghttp2/1.59.0 librtmp/2.3 OpenLDAP/2.6.10
dotnet10.0.301
gogo version go1.26.4 linux/amd64
jrubyjruby 10.1.0.0 (4.0.0) 2026-04-20 32f988b78c OpenJDK 64-Bit Server VM 25.0.3+9-LTS on 25.0.3+9-LTS +indy +jit [x86_64-linux]
mise2026.5.15 linux-x64 (2026-05-23)
nodev26.3.0
python3Python 3.14.6
rubyruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [x86_64-linux]
rustcrustc 1.95.0 (59807616e 2026-04-14)
shardsShards 0.20.0 [b2b98ca] (2025-12-19)
sqlite33.45.1 2024-01-30 16:01:20 e876e51a0ed5c5b3126f52e532044363a014bc594cfefa87ffb5b82257ccalt1 (64-bit)
uvuv 0.11.22 (x86_64-unknown-linux-musl)
wrkwrk debian/4.1.0-4build2 [epoll] Copyright (C) 2012 Will Glozer

Harness

commandscripts/bench --port 19000 rails rails-int rails-jruby ruby ruby-int jruby python spinel typescript elixir go csharp csharp-aot swift kotlin crystal rust
workers1
concurrency64
runs3 × 20s after 20s warmup
wrk threads2
endpoints/articles /articles/1 /articles/new /articles.json /articles/1.json
targetsrails, rails-int, rails-jruby, ruby, ruby-int, jruby, python, spinel, typescript, elixir, go, csharp, csharp-aot, swift, kotlin, crystal, rust

Source

commit150ad2ebe58cbdd60c8938847805560700595308
branchmain
subjectfacade(sponge): real OpenSSL::Random.random_bytes for the spinel lane

Conditions at start

load average0.33 / 0.13 / 0.04
uptime06:00:16 up 33 days, 12:46, 3 users, load average: 0.33, 0.13, 0.04

Generated 2026-07-16T04:33:59Z from summary.json.