Roundhouse benchmark results

Run bench · captured 2026-08-25T04:00:42Z · c4f262354dcd · 80 cells × 3 runs · 5 endpoints × 16 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,000req/seckotlin56,032rust53,538csharp-aot43,765csharp40,654jruby24,806crystal19,525swift19,375typescript11,762spinel8,915go8,576elixir6,129ruby3,091ruby-int2,143rails-jruby1,136rails-int333rails326

/articles/1

1001,00010,000100,000req/secrust79,038kotlin66,123csharp-aot54,606csharp53,850jruby26,438crystal25,935swift21,399elixir13,661typescript13,638spinel10,666go8,963ruby3,180ruby-int2,507rails-jruby1,051rails-int323rails315

/articles/new

1001,00010,000100,0001,000,000req/seckotlin138,910csharp-aot106,433rust104,283csharp97,093crystal52,110elixir45,566jruby36,641typescript26,656swift26,641spinel15,504go11,568ruby4,270ruby-int3,137rails-jruby1,727rails-int464rails454

/articles.json

1001,00010,000100,000req/secrust85,667kotlin64,493csharp-aot55,091csharp54,348swift38,259jruby31,639crystal28,697go20,608elixir18,156typescript13,387spinel12,576ruby3,853ruby-int2,902rails-jruby886rails-int614rails574

/articles/1.json

1001,00010,000100,0001,000,000req/secrust154,178kotlin105,969csharp-aot105,116csharp96,922crystal54,950jruby44,901swift44,085go37,737elixir28,931spinel21,840typescript21,338ruby5,240ruby-int3,933rails-jruby1,185rails974rails-int941

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,091326/articles9.5×3,180315/articles/110.1×4,270454/articles/new9.4×3,853574/articles.json6.7×5,240974/articles/1.json5.4×rubyrails

The same comparison on the JVM: emitted jruby beats stock rails-jruby by 22× 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,0912,143326333/articles3,1802,507315323/articles/14,2703,137454464/articles/new3,8532,902574614/articles.json5,2403,933974941/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,127,886crystal767,731csharp-aot614,242spinel466,701swift439,370csharp330,769go262,988kotlin105,175elixir50,614typescript37,077jruby27,193ruby21,722ruby-int19,815rails-jruby1,092rails-int1,009rails936

/articles/1

1001,00010,000100,0001,000,00010,000,000req/sec/GBrust4,516,017crystal1,084,008csharp-aot743,434spinel558,364swift477,747csharp414,158go261,480elixir112,154kotlin99,759typescript43,022jruby25,908ruby-int22,964ruby21,564rails-int945rails-jruby916rails882

/articles/new

1001,00010,000100,0001,000,00010,000,000req/sec/GBrust5,791,775crystal2,457,818csharp-aot1,445,570spinel807,072csharp744,743swift579,669elixir373,106go337,130kotlin193,513typescript83,983jruby35,623ruby26,704ruby-int23,441rails-jruby1,405rails-int1,347rails1,258

/articles.json

1001,00010,000100,0001,000,00010,000,000req/sec/GBrust4,749,843crystal1,381,634swift829,834csharp-aot748,938spinel634,234go566,578csharp421,135elixir143,629kotlin81,290typescript43,195jruby30,073ruby23,259ruby-int20,408rails-int1,741rails1,582rails-jruby715

/articles/1.json

1001,00010,000100,0001,000,00010,000,000req/sec/GBrust8,458,973crystal2,625,299csharp-aot1,428,566spinel1,094,292go1,037,714swift951,026csharp746,099elixir235,923kotlin130,114typescript70,329jruby42,672ruby28,297ruby-int24,391rails2,413rails-int2,381rails-jruby955

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

rust18 MBspinel20 MBcrystal26 MBgo37 MBswift47 MBcsharp-aot75 MBelixir129 MBcsharp133 MBruby-int165 MBruby189 MBtypescript325 MBrails-int404 MBrails413 MBkotlin833 MBjruby1,077 MBrails-jruby1,270 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.70.31.0
kotlin0.94.60.83.90.32.30.84.10.42.7
csharp1.34.71.04.20.43.31.04.40.43.1
csharp-aot1.34.01.03.60.42.41.03.30.42.1
jruby2.44.72.34.51.63.82.04.01.43.4
crystal3.34.02.43.01.11.72.22.91.11.5
swift3.16.02.76.22.25.51.62.81.42.4
go7.118.56.815.82.844.92.97.81.56.4
elixir10.315.84.68.31.24.13.46.82.14.4
typescript5.310.44.58.82.34.44.68.82.95.5
spinel7.18.26.07.04.14.95.06.02.93.9
ruby20.925.920.225.015.119.616.722.212.216.2
ruby-int30.234.526.033.020.425.322.227.816.421.2
rails-jruby52.374.856.381.433.253.670.0102.851.075.2
rails-int186.0359.0192.6402.7134.1236.2104.2117.567.6169.7
rails190.0366.1196.8509.0136.9235.3112.7131.967.7155.1

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 80 cells the median run-to-run coefficient of variation in req/sec is 0.65% — the timed runs of a given cell agree to a fraction of a percent, so the reported medians aren't masking noise.

The 8 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/articles6261,1361,14625.0%
rails-int/articles/1.json9319411,1048.0%
ruby-int/articles2,1432,1232,3855.4%
ruby-int/articles/new3,4543,1243,1374.7%
ruby-int/articles/12,3082,5072,5184.0%
ruby/articles.json3,7773,8534,1183.7%
rails-jruby/articles/19771,0611,0513.6%
ruby-int/articles.json2,9023,0982,8913.2%

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

Raw cell data (80 rows)
targetendpointreq/secp50 (ms)p99 (ms)RSS (MB)req/sec/GB
rust/articles53,5381.162.17173,127,886
rust/articles/179,0380.712.48174,516,017
rust/articles/new104,2830.471.60185,791,775
rust/articles.json85,6670.691.74184,749,843
rust/articles/1.json154,1780.340.97188,458,973
crystal/articles19,5253.323.9526767,731
crystal/articles/125,9352.383.04241,084,008
crystal/articles/new52,1101.131.72212,457,818
crystal/articles.json28,6972.152.92211,381,634
crystal/articles/1.json54,9501.131.52212,625,299
go/articles8,5767.0818.5233262,988
go/articles/18,9636.7915.8035261,480
go/articles/new11,5682.8144.8635337,130
go/articles.json20,6082.877.8237566,578
go/articles/1.json37,7371.516.37371,037,714
csharp-aot/articles43,7651.274.0172614,242
csharp-aot/articles/154,6061.013.6075743,434
csharp-aot/articles/new106,4330.422.35751,445,570
csharp-aot/articles.json55,0911.003.2775748,938
csharp-aot/articles/1.json105,1160.442.11751,428,566
kotlin/articles56,0320.924.60545105,175
kotlin/articles/166,1230.783.9267899,759
kotlin/articles/new138,9100.342.29735193,513
kotlin/articles.json64,4930.814.1081281,290
kotlin/articles/1.json105,9690.442.71833130,114
swift/articles19,3753.096.0445439,370
swift/articles/121,3992.736.2045477,747
swift/articles/new26,6412.175.5447579,669
swift/articles.json38,2591.572.8247829,834
swift/articles/1.json44,0851.432.4147951,026
csharp/articles40,6541.274.68125330,769
csharp/articles/153,8500.974.15133414,158
csharp/articles/new97,0930.433.34133744,743
csharp/articles.json54,3480.974.38132421,135
csharp/articles/1.json96,9220.453.13133746,099
spinel/articles8,9157.108.2419466,701
spinel/articles/110,6665.957.0519558,364
spinel/articles/new15,5044.074.8819807,072
spinel/articles.json12,5765.016.0320634,234
spinel/articles/1.json21,8402.863.86201,094,292
elixir/articles6,12910.3115.7912450,614
elixir/articles/113,6614.598.33124112,154
elixir/articles/new45,5661.234.08125373,106
elixir/articles.json18,1563.456.79129143,629
elixir/articles/1.json28,9312.124.41125235,923
typescript/articles11,7625.2710.4532437,077
typescript/articles/113,6384.488.8532443,022
typescript/articles/new26,6562.314.4532583,983
typescript/articles.json13,3874.628.7831743,195
typescript/articles/1.json21,3382.865.5431070,329
ruby/articles3,09120.9125.8814521,722
ruby/articles/13,18020.1524.9715121,564
ruby/articles/new4,27015.0619.6216326,704
ruby/articles.json3,85316.6922.2016923,259
ruby/articles/1.json5,24012.1516.1618928,297
ruby-int/articles2,14330.1734.5211019,815
ruby-int/articles/12,50726.0532.9611122,964
ruby-int/articles/new3,13720.3825.3213723,441
ruby-int/articles.json2,90222.2227.7514520,408
ruby-int/articles/1.json3,93316.4321.2516524,391
jruby/articles24,8062.454.7393427,193
jruby/articles/126,4382.334.541,04425,908
jruby/articles/new36,6411.653.831,05335,623
jruby/articles.json31,6391.954.001,07730,073
jruby/articles/1.json44,9011.363.351,07742,672
rails/articles326189.99366.06357936
rails/articles/1315196.75509.02366882
rails/articles/new454136.87235.343701,258
rails/articles.json574112.72131.903711,582
rails/articles/1.json97467.67155.144132,413
rails-int/articles333186.03359.033381,009
rails-int/articles/1323192.61402.72349945
rails-int/articles/new464134.10236.193531,347
rails-int/articles.json614104.17117.523611,741
rails-int/articles/1.json94167.64169.704042,381
rails-jruby/articles1,13652.2874.831,0641,092
rails-jruby/articles/11,05156.3481.441,175916
rails-jruby/articles/new1,72733.1653.601,2581,405
rails-jruby/articles.json88669.97102.831,269715
rails-jruby/articles/1.json1,18550.9775.241,270955
Environment (captured 2026-08-25T04:00:42Z)

Host

hostnameshowcase.party
OSUbuntu 24.04.4 LTS
kernelLinux showcase.party 6.8.0-137-generic #137-Ubuntu SMP PREEMPT_DYNAMIC Fri Jul 17 20:28:23 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 68ed2b13ccd2 [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
not measuredpython (requested but produced no measurements)

Source

commitc4f262354dcda0b03c738a889b2a187b97ad0ddc
branchmain
subjectCI red: a half-answer must not travel down an inheritance chain

Conditions at start

load average1.63 / 0.49 / 0.17
uptime06:00:43 up 17 days, 21:35, 3 users, load average: 1.63, 0.49, 0.17

Generated 2026-08-25T18:33:23Z from summary.json.