Roundhouse benchmark results

Run bench · captured 2026-09-14T04:00:47Z · 3065ee19570f · 85 cells × 3 runs · 5 endpoints × 17 targets
Artifacts: env.json · per-run.json · skipped.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.5 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 compiled by Spinel behind its thread-per-connection server (a green thread per connection on the runtime's autodetected OS worker count) 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,000req/seckotlin55,223rust53,086csharp-aot43,503csharp35,892spinel30,642swift19,617jruby19,348crystal19,346typescript11,573go8,576elixir6,175python5,388ruby2,974ruby-int2,092rails-jruby1,148rails-int334rails325

/articles/1

1001,00010,000100,000req/secrust79,415kotlin65,032csharp-aot54,752csharp50,062spinel38,934crystal25,515jruby22,703swift21,564elixir13,317typescript12,887go8,954python6,168ruby3,104ruby-int2,251rails-jruby1,067rails-int325rails322

/articles/new

1001,00010,000100,0001,000,000req/seckotlin136,902csharp-aot105,061rust103,599csharp84,640spinel53,456crystal50,999elixir45,812jruby33,241swift27,092typescript26,694go11,454python8,970ruby3,679ruby-int2,923rails-jruby1,763rails-int472rails469

/articles.json

1001,00010,000100,000req/secrust86,166kotlin64,934csharp-aot54,336csharp52,942swift38,863spinel32,786crystal28,648jruby26,470go20,720elixir18,196typescript13,461python6,948ruby3,773ruby-int2,930rails-jruby904rails-int613rails597

/articles/1.json

1001,00010,000100,0001,000,000req/secrust153,446kotlin105,979csharp-aot102,212csharp94,118spinel55,523crystal54,098swift46,096jruby40,567go37,982elixir28,899typescript21,694python9,704ruby5,266ruby-int3,889rails-jruby1,196rails947rails-int909

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,0002,974325/articles9.1×3,104322/articles/19.6×3,679469/articles/new7.8×3,773597/articles.json6.3×5,266947/articles/1.json5.6×rubyrails

The same comparison on the JVM: emitted jruby beats stock rails-jruby by 17× 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,0002,9742,092325334/articles3,1042,251322325/articles/13,6792,923469472/articles/new3,7732,930597613/articles.json5,2663,889947909/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

1001,00010,000100,0001,000,00010,000,000req/sec/GBrust3,074,771crystal717,642csharp-aot605,995swift444,519spinel443,509csharp288,490go262,608kotlin104,688python68,732elixir49,957typescript38,090ruby25,745jruby20,597ruby-int19,870rails-jruby1,104rails-int1,005rails938

/articles/1

1001,00010,000100,0001,000,00010,000,000req/sec/GBrust4,469,390crystal1,056,656csharp-aot747,864spinel566,652swift466,487csharp392,255go263,461elixir107,082kotlin98,906python78,552typescript42,460ruby25,286jruby20,242ruby-int17,571rails-int956rails-jruby929rails902

/articles/new

1001,00010,000100,0001,000,00010,000,000req/sec/GBrust5,667,370crystal2,323,845csharp-aot1,426,188spinel881,558csharp665,550swift581,571elixir374,202go335,093kotlin194,374python114,205typescript88,142jruby29,625ruby25,895ruby-int22,134rails-jruby1,520rails-int1,371rails1,280

/articles.json

1001,00010,000100,0001,000,00010,000,000req/sec/GBrust4,758,381crystal1,337,002swift823,318csharp-aot745,723go570,688spinel564,035csharp411,195elixir143,131python88,461kotlin83,412typescript45,357ruby25,814jruby23,546ruby-int20,457rails-int1,771rails1,601rails-jruby744

/articles/1.json

1001,00010,000100,0001,000,00010,000,000req/sec/GBrust8,431,164crystal2,515,372csharp-aot1,391,405go1,043,051spinel986,915swift976,646csharp720,702elixir227,582kotlin132,701python123,539typescript73,389jruby36,081ruby30,742ruby-int24,501rails-int2,421rails2,332rails-jruby982

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.

elixir2.9×go2.4×swift2.0×rails1.8×rust1.6×crystal1.5×csharp1.5×jruby1.4×python1.3×ruby1.3×csharp-aot1.2×kotlin1.2×typescript1.2×spinel1.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.

rust18 MBcrystal27 MBgo37 MBswift48 MBspinel70 MBcsharp-aot75 MBpython80 MBelixir130 MBcsharp133 MBruby-int162 MBruby175 MBtypescript311 MBrails-int384 MBrails415 MBkotlin817 MBjruby1,151 MBrails-jruby1,246 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.30.51.60.71.80.31.0
kotlin0.94.60.83.60.32.40.84.00.42.7
csharp1.35.11.04.50.53.81.04.80.53.6
csharp-aot1.33.61.03.60.42.21.03.20.42.3
spinel2.24.91.74.31.03.42.04.41.03.2
swift3.15.92.76.12.15.51.62.71.42.3
crystal3.24.02.43.11.21.62.22.91.11.6
jruby3.25.52.75.11.84.02.34.51.53.6
go7.118.56.815.92.845.22.97.71.56.5
elixir10.215.74.78.61.24.13.46.82.14.4
typescript5.310.64.68.42.34.54.59.02.85.6
python11.812.410.410.87.17.69.29.76.47.4
ruby22.025.821.226.816.633.517.321.312.116.1
ruby-int31.237.128.834.921.436.722.428.116.521.8
rails-jruby51.480.555.283.132.552.968.094.250.074.0
rails-int185.6347.1190.8410.8132.0228.6103.7130.769.7147.8
rails190.1367.1193.1459.0132.1230.3106.8133.169.2153.9

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.44% — the timed runs of a given cell agree to a fraction of a percent, so the reported medians aren't masking noise.

The 12 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 6 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/articles6671,1531,14823.0%
ruby-int/articles/1.json3,6354,2913,8896.9%
rails-int/articles/1.json1,0189098716.7%
ruby/articles/12,9313,3683,1045.7%
ruby/articles/1.json5,2665,2085,8855.6%
ruby-int/articles1,9262,1922,0925.3%
ruby/articles2,9743,0952,7454.9%
ruby-int/articles.json2,9303,0602,7674.1%
typescript/articles/112,88713,53712,5123.3%
rails-jruby/articles.json8459049073.3%
rails-jruby/articles/19981,0671,0703.2%
csharp-aot/articles/new105,06198,480105,7203.2%

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,0861.182.19173,074,771
rust/articles/179,4150.702.33184,469,390
rust/articles/new103,5990.471.60185,667,370
rust/articles.json86,1660.681.79184,758,381
rust/articles/1.json153,4460.340.97188,431,164
crystal/articles19,3463.243.9827717,642
crystal/articles/125,5152.413.12241,056,656
crystal/articles/new50,9991.181.56222,323,845
crystal/articles.json28,6482.162.89211,337,002
crystal/articles/1.json54,0981.121.55222,515,372
go/articles8,5767.0918.5433262,608
go/articles/18,9546.8115.9034263,461
go/articles/new11,4542.8345.1535335,093
go/articles.json20,7202.867.7237570,688
go/articles/1.json37,9821.496.48371,043,051
csharp-aot/articles43,5031.283.5573605,995
csharp-aot/articles/154,7521.013.6174747,864
csharp-aot/articles/new105,0610.432.18751,426,188
csharp-aot/articles.json54,3361.013.2374745,723
csharp-aot/articles/1.json102,2120.442.29751,391,405
kotlin/articles55,2230.944.55540104,688
kotlin/articles/165,0320.803.5867398,906
kotlin/articles/new136,9020.342.40721194,374
kotlin/articles.json64,9340.803.9579783,412
kotlin/articles/1.json105,9790.442.72817132,701
swift/articles19,6173.065.8945444,519
swift/articles/121,5642.716.0747466,487
swift/articles/new27,0922.125.5247581,571
swift/articles.json38,8631.552.7148823,318
swift/articles/1.json46,0961.382.3148976,646
csharp/articles35,8921.325.10127288,490
csharp/articles/150,0620.994.53130392,255
csharp/articles/new84,6400.463.78130665,550
csharp/articles.json52,9420.994.79131411,195
csharp/articles/1.json94,1180.463.58133720,702
spinel/articles30,6422.184.9070443,509
spinel/articles/138,9341.674.3470566,652
spinel/articles/new53,4561.023.3962881,558
spinel/articles.json32,7862.054.3659564,035
spinel/articles/1.json55,5230.963.2257986,915
elixir/articles6,17510.2315.6712649,957
elixir/articles/113,3174.708.57127107,082
elixir/articles/new45,8121.234.11125374,202
elixir/articles.json18,1963.456.76130143,131
elixir/articles/1.json28,8992.124.42130227,582
typescript/articles11,5735.3210.5631138,090
typescript/articles/112,8874.658.3631042,460
typescript/articles/new26,6942.324.4831088,142
typescript/articles.json13,4614.528.9530345,357
typescript/articles/1.json21,6942.845.6130273,389
python/articles5,38811.8512.438068,732
python/articles/16,16810.3510.758078,552
python/articles/new8,9707.087.5680114,205
python/articles.json6,9489.189.698088,461
python/articles/1.json9,7046.457.4480123,539
ruby/articles2,97422.0425.8311825,745
ruby/articles/13,10421.1826.8012525,286
ruby/articles/new3,67916.5833.4914525,895
ruby/articles.json3,77317.2721.3014925,814
ruby/articles/1.json5,26612.1416.0917530,742
ruby-int/articles2,09231.2537.1410719,870
ruby-int/articles/12,25128.8034.8813117,571
ruby-int/articles/new2,92321.3836.6613522,134
ruby-int/articles.json2,93022.3728.1114620,457
ruby-int/articles/1.json3,88916.5121.7616224,501
jruby/articles19,3483.175.5296120,597
jruby/articles/122,7032.715.061,14820,242
jruby/articles/new33,2411.844.051,14829,625
jruby/articles.json26,4702.334.471,15123,546
jruby/articles/1.json40,5671.513.561,15136,081
rails/articles325190.11367.12355938
rails/articles/1322193.09458.95365902
rails/articles/new469132.12230.263751,280
rails/articles.json597106.81133.103811,601
rails/articles/1.json94769.19153.924152,332
rails-int/articles334185.60347.133401,005
rails-int/articles/1325190.80410.80348956
rails-int/articles/new472132.01228.653521,371
rails-int/articles.json613103.71130.673541,771
rails-int/articles/1.json90969.69147.783842,421
rails-jruby/articles1,14851.4480.491,0631,104
rails-jruby/articles/11,06755.2483.071,175929
rails-jruby/articles/new1,76332.5252.901,1871,520
rails-jruby/articles.json90468.0094.181,244744
rails-jruby/articles/1.json1,19649.9974.001,246982
Environment (captured 2026-09-14T04:00:47Z)

Host

hostnameshowcase.party
OSUbuntu 24.04.5 LTS
kernelLinux showcase.party 6.8.0-139-generic #139-Ubuntu SMP PREEMPT_DYNAMIC Sat Aug 1 03:52:05 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,228 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
redis-serverRedis server v=7.0.15 sha=00000000:0 malloc=jemalloc-5.3.0 bits=64 build=e53ff17674aa6190
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)
spinelspinel 2026.09.12+53 (a4743817) [gcc 13.3.0 (cc)]
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

commit3065ee19570f4fe738b2d893fbfab27ec71a909c
branchmain
subjectbench-campfire: --skip-prepare keeps the spinel binary too, so an A/B can run against a rebuilt one

Conditions at start

load average1.45 / 0.47 / 0.15
uptime06:00:48 up 1 day, 4:42, 0 user, load average: 1.45, 0.47, 0.15

Generated 2026-09-14T21:02:50Z from summary.json.