Roundhouse benchmark results

Run bench · captured 2026-09-10T04:00:02Z · a604f622624b · 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.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 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,677rust52,899csharp-aot43,267csharp40,120spinel20,218swift19,586jruby19,216crystal19,052typescript11,575go8,565elixir6,191python5,248ruby3,006ruby-int2,032rails-jruby1,132rails-int332rails325

/articles/1

1001,00010,000100,000req/secrust78,731kotlin65,740csharp-aot54,515csharp53,266crystal25,188spinel22,882jruby21,826swift21,451typescript13,423elixir13,364go8,941python6,152ruby2,978ruby-int2,268rails-jruby1,057rails-int325rails318

/articles/new

1001,00010,000100,0001,000,000req/seckotlin136,644rust103,258csharp-aot103,204csharp93,914crystal50,514elixir45,350jruby33,228spinel27,619swift27,000typescript26,150go11,456python8,791ruby3,676ruby-int2,934rails-jruby1,745rails-int476rails459

/articles.json

1001,00010,000100,000req/secrust85,510kotlin63,569csharp54,133csharp-aot53,982swift39,118crystal27,942jruby27,150spinel22,570go20,699elixir18,182typescript13,410python6,827ruby3,972ruby-int2,864rails-jruby891rails-int608rails575

/articles/1.json

1001,00010,000100,0001,000,000req/secrust153,470kotlin104,734csharp-aot103,162csharp95,574crystal51,384swift45,910jruby43,017go37,270spinel29,923elixir28,832typescript20,954python9,520ruby5,352ruby-int3,875rails-jruby1,188rails-int941rails922

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,006325/articles9.2×2,978318/articles/19.4×3,676459/articles/new8.0×3,972575/articles.json6.9×5,352922/articles/1.json5.8×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,0003,0062,032325332/articles2,9782,268318325/articles/13,6762,934459476/articles/new3,9722,864575608/articles.json5,3523,875922941/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,118,329crystal752,425csharp-aot600,190swift442,511csharp333,335go261,746spinel242,113kotlin108,722python66,026elixir47,169typescript38,530ruby26,212jruby20,555ruby-int18,962rails-jruby1,111rails-int1,008rails935

/articles/1

1001,00010,000100,0001,000,00010,000,000req/sec/GBrust4,527,114crystal1,014,305csharp-aot745,094swift474,942csharp437,026spinel297,426go265,793elixir103,039kotlin101,390python77,365typescript44,709ruby25,252ruby-int20,978jruby19,932rails-jruby975rails-int956rails888

/articles/new

1001,00010,000100,0001,000,00010,000,000req/sec/GBrust5,640,440crystal2,387,266csharp-aot1,381,881csharp762,511swift590,723spinel366,337elixir356,529go335,831kotlin194,242python110,550typescript96,263jruby30,609ruby30,531ruby-int26,388rails-jruby1,604rails-int1,358rails1,277

/articles.json

1001,00010,000100,0001,000,00010,000,000req/sec/GBrust4,655,448crystal1,289,597swift846,296csharp-aot743,277go567,537csharp435,047spinel297,323elixir141,201python85,857kotlin82,263typescript45,396ruby31,511ruby-int25,530jruby25,001rails-int1,703rails1,594rails-jruby793

/articles/1.json

1001,00010,000100,0001,000,00010,000,000req/sec/GBrust8,217,168crystal2,358,208csharp-aot1,406,458go1,020,175swift989,489csharp770,118spinel428,439elixir228,623kotlin132,153python119,717typescript71,190ruby39,749jruby39,608ruby-int32,079rails-int2,407rails2,315rails-jruby1,057

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×jruby1.4×csharp1.3×ruby1.3×python1.3×csharp-aot1.2×typescript1.2×kotlin1.1×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.

rust19 MBcrystal25 MBgo37 MBswift47 MBcsharp-aot76 MBpython81 MBspinel85 MBruby-int123 MBcsharp127 MBelixir134 MBruby137 MBtypescript307 MBrails-int400 MBrails408 MBkotlin811 MBjruby1,121 MBrails-jruby1,151 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.40.51.60.71.70.31.0
kotlin0.94.40.83.60.32.40.83.90.42.7
csharp1.34.71.04.10.43.41.04.40.52.7
csharp-aot1.34.11.03.70.42.81.03.40.52.2
spinel2.2688.71.9615.01.6827.61.9757.91.4839.8
swift3.16.02.76.12.15.51.52.71.42.3
crystal3.44.12.43.21.21.62.23.01.21.7
jruby3.25.62.85.11.84.02.34.41.43.4
go7.118.66.815.92.845.22.97.71.56.5
elixir10.215.84.78.51.24.13.46.92.14.4
typescript5.310.54.58.92.44.64.68.92.95.6
python12.312.510.411.37.47.69.59.76.77.4
ruby22.227.022.026.617.921.716.920.512.115.3
ruby-int31.739.328.433.722.226.922.827.216.620.7
rails-jruby52.475.355.884.132.653.170.093.050.075.0
rails-int186.6353.6193.5446.3130.3223.4104.1128.568.9170.3
rails190.4352.0195.0423.1135.5229.5112.3137.170.2172.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.81% — 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 5 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/articles7151,1321,13619.9%
spinel/articles.json18,93522,77922,5708.2%
ruby/articles/new3,6113,6764,2817.8%
ruby/articles3,0062,9133,3696.4%
spinel/articles/new27,61926,34130,1055.6%
csharp-aot/articles/1.json103,162105,63194,0214.9%
spinel/articles/1.json29,92332,45429,7834.0%
rails-int/articles/1.json8799419643.8%
spinel/articles19,19420,90620,2183.5%
ruby/articles/1.json5,5825,1315,3523.4%
ruby-int/articles/12,2682,3372,1503.4%
ruby-int/articles2,0322,1281,9663.3%

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/articles52,8991.182.21173,118,329
rust/articles/178,7310.722.42174,527,114
rust/articles/new103,2580.471.59185,640,440
rust/articles.json85,5100.691.74184,655,448
rust/articles/1.json153,4700.340.98198,217,168
crystal/articles19,0523.374.0925752,425
crystal/articles/125,1882.413.20251,014,305
crystal/articles/new50,5141.171.62212,387,266
crystal/articles.json27,9422.182.98221,289,597
crystal/articles/1.json51,3841.211.66222,358,208
go/articles8,5657.0918.5933261,746
go/articles/18,9416.8015.8834265,793
go/articles/new11,4562.8345.1834335,831
go/articles.json20,6992.867.7437567,537
go/articles/1.json37,2701.536.50371,020,175
csharp-aot/articles43,2671.294.0873600,190
csharp-aot/articles/154,5151.013.6874745,094
csharp-aot/articles/new103,2040.432.75761,381,881
csharp-aot/articles.json53,9821.013.3774743,277
csharp-aot/articles/1.json103,1620.452.21751,406,458
kotlin/articles55,6770.934.35524108,722
kotlin/articles/165,7400.783.60663101,390
kotlin/articles/new136,6440.342.37720194,242
kotlin/articles.json63,5690.823.9179182,263
kotlin/articles/1.json104,7340.442.68811132,153
swift/articles19,5863.065.9745442,511
swift/articles/121,4512.736.1146474,942
swift/articles/new27,0002.145.5246590,723
swift/articles.json39,1181.542.7147846,296
swift/articles/1.json45,9101.392.3247989,489
csharp/articles40,1201.274.72123333,335
csharp/articles/153,2660.984.10124437,026
csharp/articles/new93,9140.453.44126762,511
csharp/articles.json54,1330.994.36127435,047
csharp/articles/1.json95,5740.462.73127770,118
spinel/articles20,2182.24688.7285242,113
spinel/articles/122,8821.91615.0278297,426
spinel/articles/new27,6191.55827.6277366,337
spinel/articles.json22,5701.91757.9277297,323
spinel/articles/1.json29,9231.35839.8471428,439
elixir/articles6,19110.2015.7913447,169
elixir/articles/113,3644.708.54132103,039
elixir/articles/new45,3501.234.11130356,529
elixir/articles.json18,1823.436.88131141,201
elixir/articles/1.json28,8322.124.41129228,623
typescript/articles11,5755.3410.5130738,530
typescript/articles/113,4234.528.9130744,709
typescript/articles/new26,1502.374.5527896,263
typescript/articles.json13,4104.558.8930245,396
typescript/articles/1.json20,9542.935.6430171,190
python/articles5,24812.3012.498166,026
python/articles/16,15210.3711.348177,365
python/articles/new8,7917.377.6281110,550
python/articles.json6,8279.489.728185,857
python/articles/1.json9,5206.717.4281119,717
ruby/articles3,00622.2426.9511726,212
ruby/articles/12,97822.0426.5712025,252
ruby/articles/new3,67617.8721.7312330,531
ruby/articles.json3,97216.8620.4912931,511
ruby/articles/1.json5,35212.1215.2613739,749
ruby-int/articles2,03231.7239.3110918,962
ruby-int/articles/12,26828.4333.6911020,978
ruby-int/articles/new2,93422.1926.9111326,388
ruby-int/articles.json2,86422.8527.1611425,530
ruby-int/articles/1.json3,87516.5520.7412332,079
jruby/articles19,2163.215.5895720,555
jruby/articles/121,8262.835.121,12119,932
jruby/articles/new33,2281.844.051,11130,609
jruby/articles.json27,1502.264.421,11225,001
jruby/articles/1.json43,0171.423.441,11239,608
rails/articles325190.45351.98356935
rails/articles/1318194.99423.11366888
rails/articles/new459135.46229.473681,277
rails/articles.json575112.34137.103691,594
rails/articles/1.json92270.25172.034082,315
rails-int/articles332186.62353.603371,008
rails-int/articles/1325193.46446.32348956
rails-int/articles/new476130.31223.453581,358
rails-int/articles.json608104.10128.523651,703
rails-int/articles/1.json94168.93170.294002,407
rails-jruby/articles1,13252.4075.281,0431,111
rails-jruby/articles/11,05755.7784.141,110975
rails-jruby/articles/new1,74532.6053.091,1131,604
rails-jruby/articles.json89169.9892.971,150793
rails-jruby/articles/1.json1,18850.0174.961,1511,057
Environment (captured 2026-09-10T04:00:02Z)

Host

hostnameshowcase.party
OSUbuntu 24.04.4 LTS
kernelLinux showcase.party 6.8.0-138-generic #138-Ubuntu SMP PREEMPT_DYNAMIC Fri Jul 31 22:41:49 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 ba502fba0092 (unreleased) [cc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0]
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

commita604f622624b9185b2050996c71e67ea69515540
branchmain
subjectA relative --tally is not a tally somewhere else

Conditions at start

load average0.1 / 0.06 / 0.03
uptime06:00:03 up 5 days, 9:43, 8 users, load average: 0.10, 0.06, 0.03

Generated 2026-09-10T22:36:37Z from summary.json.