Roundhouse benchmark results

Run bench · captured 2026-07-14T04:00:03Z · b21defd6e16a · 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,351rust50,541csharp-aot42,778csharp35,346jruby23,201crystal19,729swift19,692typescript11,569spinel10,433go8,361elixir6,292python4,915ruby3,266ruby-int2,250rails-jruby1,077rails-int330rails325

/articles/1

1001,00010,000100,0001,000,000req/secrust73,847kotlin65,628csharp-aot53,690csharp48,576jruby26,099crystal25,570swift21,446typescript13,560elixir13,223spinel12,381go8,654python6,375ruby3,147ruby-int2,500rails-jruby1,066rails-int326rails317

/articles/new

1001,00010,000100,0001,000,000req/seckotlin137,490csharp-aot103,628rust102,682csharp83,291crystal51,091elixir45,642jruby38,422typescript26,131swift25,200spinel19,711go11,091python9,676ruby4,468ruby-int3,382rails-jruby1,741rails-int475rails468

/articles.json

1001,00010,000100,0001,000,000req/secrust85,447kotlin64,682csharp-aot53,581csharp52,314swift38,133jruby29,778crystal28,162go20,358elixir18,195spinel13,802typescript13,443python8,900ruby3,934ruby-int2,877rails-jruby891rails-int618rails596

/articles/1.json

1001,00010,000100,0001,000,000req/secrust153,420kotlin104,492csharp89,974csharp-aot80,911crystal52,957swift43,350jruby42,280go36,159elixir27,953spinel24,235typescript21,179python10,652ruby5,329ruby-int4,060rails-jruby1,182rails-int989rails917

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,266325/articles10.0×3,147317/articles/19.9×4,468468/articles/new9.5×3,934596/articles.json6.6×5,329917/articles/1.json5.8×rubyrails

The same comparison on the JVM: emitted jruby beats stock rails-jruby by 22× on /articles, against 10× 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,2662,250325330/articles3,1472,500317326/articles/14,4683,382468475/articles/new3,9342,877596618/articles.json5,3294,060917989/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/GBrust2,904,256spinel892,906crystal740,448csharp-aot593,983swift444,863csharp294,245go247,794kotlin97,496python62,212elixir47,362typescript36,524ruby30,433jruby24,363ruby-int23,525rails-int1,161rails1,078rails-jruby1,015

/articles/1

1,00010,000100,0001,000,00010,000,000req/sec/GBrust4,102,298spinel1,065,205crystal1,033,958csharp-aot735,486swift466,168csharp395,554go248,647kotlin99,155elixir98,531python80,427typescript42,561ruby28,519jruby24,897ruby-int21,959rails-int1,115rails1,011rails-jruby847

/articles/new

1,00010,000100,0001,000,00010,000,000req/sec/GBrust5,554,590crystal2,369,648spinel1,708,731csharp-aot1,384,057csharp672,609swift515,060elixir341,125go318,580kotlin194,169python122,074typescript81,970jruby36,618ruby32,299ruby-int26,766rails-int1,584rails1,467rails-jruby1,318

/articles.json

1,00010,000100,0001,000,00010,000,000req/sec/GBrust4,636,615crystal1,337,920spinel1,143,564swift758,627csharp-aot723,572go559,966csharp421,341elixir131,805python112,182kotlin81,335typescript43,151jruby28,369ruby27,061ruby-int21,860rails-int2,022rails1,856rails-jruby666

/articles/1.json

1,00010,000100,0001,000,00010,000,000req/sec/GBrust8,312,991crystal2,386,545spinel1,932,802csharp-aot1,095,747go989,144swift861,566csharp717,210elixir204,288python134,259kotlin128,006typescript69,267jruby40,278ruby32,888ruby-int27,998rails-int2,851rails2,494rails-jruby882

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×swift1.9×rails1.8×python1.8×rust1.7×csharp1.5×crystal1.4×spinel1.3×jruby1.3×csharp-aot1.3×ruby1.2×kotlin1.2×typescript1.2×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 MBcrystal27 MBgo37 MBswift51 MBcsharp-aot76 MBpython81 MBcsharp128 MBelixir141 MBruby-int148 MBruby165 MBtypescript326 MBrails-int355 MBrails376 MBkotlin835 MBjruby1,074 MBrails-jruby1,371 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.40.82.70.51.60.71.80.31.0
kotlin0.94.40.83.80.32.40.84.00.42.7
csharp1.35.21.04.60.53.81.04.80.53.7
csharp-aot1.35.51.05.60.43.61.04.20.614.5
jruby2.64.92.44.61.63.72.14.11.43.4
crystal3.33.92.43.11.21.72.12.91.21.6
swift3.06.02.76.32.28.71.63.01.24.8
go7.319.07.016.42.948.32.97.91.67.2
elixir10.015.34.78.71.24.13.46.82.24.4
typescript5.310.54.59.02.44.54.68.92.95.7
spinel6.16.75.16.03.23.94.65.62.63.4
python13.013.610.010.66.67.17.28.16.06.2
ruby19.525.520.326.214.418.416.621.112.215.6
ruby-int28.933.226.332.419.224.522.528.216.221.0
rails-jruby55.379.055.682.833.252.769.6103.651.076.0
rails-int186.1366.3190.0410.5130.8232.0103.0125.766.5174.0
rails189.5365.4193.8433.0132.5226.7106.4133.770.1180.4

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 10 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/articles6841,0771,08519.7%
csharp-aot/articles.json53,58153,99744,3148.8%
rails-jruby/articles.json7888919056.0%
rails-jruby/articles/19451,0721,0665.7%
ruby/articles/1.json5,7725,2635,3294.1%
ruby/articles3,3003,2663,0064.1%
typescript/articles/1.json21,42321,17919,5873.9%
ruby/articles/13,2532,9743,1473.7%
ruby-int/articles/12,3992,5962,5003.2%
spinel/articles.json12,89513,80213,8313.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/articles50,5411.232.44172,904,256
rust/articles/173,8470.772.68184,102,298
rust/articles/new102,6820.481.63185,554,590
rust/articles.json85,4470.691.80184,636,615
rust/articles/1.json153,4200.351.02188,312,991
crystal/articles19,7293.283.9027740,448
crystal/articles/125,5702.403.09251,033,958
crystal/articles/new51,0911.161.74222,369,648
crystal/articles.json28,1622.142.93211,337,920
crystal/articles/1.json52,9571.151.55222,386,545
go/articles8,3617.2719.0234247,794
go/articles/18,6547.0316.4235248,647
go/articles/new11,0912.9348.2635318,580
go/articles.json20,3582.917.8937559,966
go/articles/1.json36,1591.567.1837989,144
csharp-aot/articles42,7781.295.4973593,983
csharp-aot/articles/153,6901.025.6274735,486
csharp-aot/articles/new103,6280.443.57761,384,057
csharp-aot/articles.json53,5811.034.1875723,572
csharp-aot/articles/1.json80,9110.5914.46751,095,747
kotlin/articles55,3510.944.3758197,496
kotlin/articles/165,6280.783.7767799,155
kotlin/articles/new137,4900.342.41725194,169
kotlin/articles.json64,6820.804.0581481,335
kotlin/articles/1.json104,4920.442.72835128,006
swift/articles19,6923.036.0145444,863
swift/articles/121,4462.726.2747466,168
swift/articles/new25,2002.158.7250515,060
swift/articles.json38,1331.572.9651758,627
swift/articles/1.json43,3501.164.8451861,566
csharp/articles35,3461.345.18123294,245
csharp/articles/148,5761.014.64125395,554
csharp/articles/new83,2910.473.81126672,609
csharp/articles.json52,3140.994.85127421,341
csharp/articles/1.json89,9740.503.70128717,210
spinel/articles10,4336.116.6911892,906
spinel/articles/112,3815.125.98111,065,205
spinel/articles/new19,7113.203.90111,708,731
spinel/articles.json13,8024.605.61121,143,564
spinel/articles/1.json24,2352.563.35121,932,802
elixir/articles6,29210.0515.3413647,362
elixir/articles/113,2234.748.6913798,531
elixir/articles/new45,6421.234.09137341,125
elixir/articles.json18,1953.456.78141131,805
elixir/articles/1.json27,9532.204.45140204,288
typescript/articles11,5695.3210.4832436,524
typescript/articles/113,5604.548.9632642,561
typescript/articles/new26,1312.404.5332681,970
typescript/articles.json13,4434.618.9131943,151
typescript/articles/1.json21,1792.885.6631369,267
python/articles4,91512.9813.598062,212
python/articles/16,3759.9910.558180,427
python/articles/new9,6766.557.1281122,074
python/articles.json8,9007.168.0781112,182
python/articles/1.json10,6525.996.1681134,259
ruby/articles3,26619.5325.5010930,433
ruby/articles/13,14720.3126.1711328,519
ruby/articles/new4,46814.3618.3814132,299
ruby/articles.json3,93416.6421.0914827,061
ruby/articles/1.json5,32912.2215.6116532,888
ruby-int/articles2,25028.9233.169723,525
ruby-int/articles/12,50026.2632.3811621,959
ruby-int/articles/new3,38219.2224.4912926,766
ruby-int/articles.json2,87722.5328.2413421,860
ruby-int/articles/1.json4,06016.2221.0514827,998
jruby/articles23,2012.644.9497524,363
jruby/articles/126,0992.364.551,07324,897
jruby/articles/new38,4221.583.661,07436,618
jruby/articles.json29,7782.074.101,07428,369
jruby/articles/1.json42,2801.453.391,07440,278
rails/articles325189.49365.393091,078
rails/articles/1317193.82432.993211,011
rails/articles/new468132.52226.663261,467
rails/articles.json596106.44133.663281,856
rails/articles/1.json91770.07180.383762,494
rails-int/articles330186.14366.302911,161
rails-int/articles/1326190.05410.542991,115
rails-int/articles/new475130.78232.013071,584
rails-int/articles.json618103.01125.673132,022
rails-int/articles/1.json98966.47174.033552,851
rails-jruby/articles1,07755.2878.991,0851,015
rails-jruby/articles/11,06655.6282.821,288847
rails-jruby/articles/new1,74133.1952.721,3531,318
rails-jruby/articles.json89169.56103.591,368666
rails-jruby/articles/1.json1,18250.9975.991,371882
Environment (captured 2026-07-14T04:00:03Z)

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

commitb21defd6e16a3a823c95fe49561d9521667d35ec
branchmain
subjectlower(view): bare button_tag rides emit_button; raw() content verbatim

Conditions at start

load average0.12 / 0.1 / 0.06
uptime06:00:03 up 32 days, 12:46, 2 users, load average: 0.12, 0.10, 0.06

Generated 2026-07-14T21:30:41Z from summary.json.