Roundhouse benchmark results

Run latest · captured 2026-07-25T14:32:25Z · 24f364217dff · 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/seckotlin54,433rust52,283csharp-aot41,330csharp34,310jruby23,097crystal19,815swift19,068typescript11,583spinel9,283go8,320elixir6,283python4,795ruby3,019ruby-int2,313rails-jruby1,125rails-int328rails323

/articles/1

1001,00010,000100,0001,000,000req/secrust77,605kotlin64,024csharp-aot51,225csharp46,835jruby25,986crystal25,305swift20,782typescript13,269elixir13,178spinel10,550go8,642python6,183ruby3,304ruby-int2,396rails-jruby1,052rails-int321rails315

/articles/new

1001,00010,000100,0001,000,000req/seckotlin136,619rust102,303csharp-aot101,635csharp79,510crystal50,096elixir45,367jruby39,043typescript26,357swift24,977go11,061python9,407spinel8,230ruby4,216ruby-int3,178rails-jruby1,738rails-int462rails454

/articles.json

1001,00010,000100,0001,000,000req/secrust84,949kotlin63,025csharp-aot51,891csharp51,055swift37,069jruby31,333crystal27,858go20,306elixir17,891typescript13,128python8,605ruby3,747ruby-int2,933rails-jruby869rails588rails-int583spinel0

/articles/1.json

1001,00010,000100,0001,000,000req/secrust152,363kotlin102,270csharp-aot98,152csharp88,288crystal52,767jruby44,406swift41,560go35,603elixir27,419typescript21,073python10,389ruby5,212ruby-int3,908rails-jruby1,154rails-int938rails898spinel0

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,0003,019323/articles9.3×3,304315/articles/110.5×4,216454/articles/new9.3×3,747588/articles.json6.4×5,212898/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,0003,0192,313323328/articles3,3042,396315321/articles/14,2163,178454462/articles/new3,7472,933588583/articles.json5,2123,908898938/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,078,588spinel776,914crystal731,685csharp-aot579,237swift426,245csharp285,017go254,596kotlin103,245python60,571elixir46,053typescript36,380jruby25,300ruby24,435ruby-int19,290rails-int1,163rails-jruby1,064rails1,036

/articles/1

1,00010,000100,0001,000,00010,000,000req/sec/GBrust4,398,664crystal1,072,627spinel887,589csharp-aot698,056swift441,844csharp379,642go252,790kotlin99,161elixir96,340python77,872typescript41,400jruby25,665ruby23,284ruby-int19,285rails-int1,116rails981rails-jruby965

/articles/new

1,00010,000100,0001,000,00010,000,000req/sec/GBrust5,585,987crystal2,378,655csharp-aot1,370,032spinel701,437csharp642,438swift513,106elixir332,900go322,104kotlin197,329python118,472typescript82,654jruby38,554ruby27,429ruby-int23,872rails-int1,546rails-jruby1,512rails1,395

/articles.json

1,00010,000100,0001,000,00010,000,000req/sec/GBrust4,656,834crystal1,269,071swift736,448csharp-aot716,059go558,276csharp409,531elixir130,272python108,371kotlin83,325typescript41,947jruby30,924ruby23,129ruby-int21,361rails-int1,890rails1,758rails-jruby724spinel0

/articles/1.json

1,00010,000100,0001,000,00010,000,000req/sec/GBrust8,338,435crystal2,477,194csharp-aot1,338,918go978,841swift823,805csharp706,870elixir202,287kotlin131,713python130,833typescript68,661jruby43,824ruby30,860ruby-int25,596rails-int2,661rails2,354rails-jruby960spinel0

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.8×go2.4×swift1.9×rails1.8×python1.8×rust1.6×csharp1.5×crystal1.4×jruby1.4×csharp-aot1.3×ruby1.2×kotlin1.2×typescript1.1×rails-jruby0.8×spinel0.0×

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-aot75 MBpython81 MBcsharp127 MBelixir140 MBruby-int156 MBruby172 MBtypescript328 MBrails-int360 MBrails390 MBkotlin795 MBjruby1,037 MBrails-jruby1,230 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.30.72.60.51.60.71.90.41.0
kotlin1.04.40.83.80.32.50.84.00.52.9
csharp1.45.41.04.80.53.91.05.10.53.8
csharp-aot1.35.81.15.30.43.51.15.30.53.4
jruby2.65.02.44.61.63.72.04.11.43.3
crystal3.34.02.43.21.21.82.23.01.21.6
swift3.16.32.86.62.28.41.63.91.25.1
go7.319.17.016.52.947.32.97.91.67.1
elixir10.115.64.88.71.24.03.56.82.24.5
typescript5.310.44.68.92.44.64.69.02.95.7
spinel6.87.76.06.82.22.60.00.00.00.0
python13.313.910.211.06.77.67.38.06.16.8
ruby21.225.919.624.315.320.216.823.412.216.0
ruby-int28.035.826.931.820.324.222.227.216.221.7
rails-jruby52.879.956.083.933.253.871.997.952.078.0
rails-int187.9467.8192.8298.8134.8338.7110.8132.169.2163.0
rails191.5468.9197.4372.8137.1346.7108.6129.970.8184.3

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

The 9 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 3 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
spinel/articles/new16,4600-100.0%
rails-jruby/articles7221,1281,12519.2%
ruby-int/articles2,5652,3132,2006.5%
ruby-int/articles/12,3962,6732,3475.8%
rails-jruby/articles/19381,0561,0525.4%
ruby/articles3,2932,9383,0194.9%
rails/articles/1.json9768958984.0%
rails-int/articles/1.json9819389053.3%
rails-jruby/articles.json8108698693.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,2831.202.27173,078,588
rust/articles/177,6050.722.65184,398,664
rust/articles/new102,3030.481.65185,585,987
rust/articles.json84,9490.701.86184,656,834
rust/articles/1.json152,3630.351.03188,338,435
crystal/articles19,8153.273.9827731,685
crystal/articles/125,3052.433.16241,072,627
crystal/articles/new50,0961.191.76212,378,655
crystal/articles.json27,8582.192.95221,269,071
crystal/articles/1.json52,7671.161.55212,477,194
go/articles8,3207.3019.1133254,596
go/articles/18,6427.0516.4735252,790
go/articles/new11,0612.9447.2735322,104
go/articles.json20,3062.927.9237558,276
go/articles/1.json35,6031.597.1237978,841
csharp-aot/articles41,3301.335.8173579,237
csharp-aot/articles/151,2251.075.3075698,056
csharp-aot/articles/new101,6350.443.49751,370,032
csharp-aot/articles.json51,8911.065.3174716,059
csharp-aot/articles/1.json98,1520.483.36751,338,918
kotlin/articles54,4330.954.44539103,245
kotlin/articles/164,0240.803.7966199,161
kotlin/articles/new136,6190.342.52708197,329
kotlin/articles.json63,0250.824.0377483,325
kotlin/articles/1.json102,2700.462.89795131,713
swift/articles19,0683.126.2645426,245
swift/articles/120,7822.796.5748441,844
swift/articles/new24,9772.248.3749513,106
swift/articles.json37,0691.593.9251736,448
swift/articles/1.json41,5601.205.1251823,805
csharp/articles34,3101.375.36123285,017
csharp/articles/146,8351.034.77126379,642
csharp/articles/new79,5100.493.94126642,438
csharp/articles.json51,0551.025.07127409,531
csharp/articles/1.json88,2880.533.81127706,870
spinel/articles9,2836.787.6612776,914
spinel/articles/110,5505.986.8312887,589
spinel/articles/new8,2302.242.6412701,437
spinel/articles.json00.000.0000
spinel/articles/1.json00.000.0000
elixir/articles6,28310.0615.5713946,053
elixir/articles/113,1784.768.7314096,340
elixir/articles/new45,3671.244.02139332,900
elixir/articles.json17,8913.526.85140130,272
elixir/articles/1.json27,4192.254.50138202,287
typescript/articles11,5835.2810.4132636,380
typescript/articles/113,2694.568.9432841,400
typescript/articles/new26,3572.364.5632682,654
typescript/articles.json13,1284.619.0032041,947
typescript/articles/1.json21,0732.885.7031468,661
python/articles4,79513.3113.918160,571
python/articles/16,18310.2111.048177,872
python/articles/new9,4076.687.6081118,472
python/articles.json8,6057.288.0181108,371
python/articles/1.json10,3896.086.7781130,833
ruby/articles3,01921.1725.8712624,435
ruby/articles/13,30419.6224.3114523,284
ruby/articles/new4,21615.2820.2015727,429
ruby/articles.json3,74716.8323.4216523,129
ruby/articles/1.json5,21212.1715.9517230,860
ruby-int/articles2,31328.0535.8012219,290
ruby-int/articles/12,39626.8931.8412719,285
ruby-int/articles/new3,17820.2624.2313623,872
ruby-int/articles.json2,93322.2027.2114021,361
ruby-int/articles/1.json3,90816.2221.7115625,596
jruby/articles23,0972.645.0293425,300
jruby/articles/125,9862.354.641,03625,665
jruby/articles/new39,0431.563.691,03638,554
jruby/articles.json31,3331.954.081,03730,924
jruby/articles/1.json44,4061.363.331,03743,824
rails/articles323191.50468.883191,036
rails/articles/1315197.36372.80328981
rails/articles/new454137.07346.743331,395
rails/articles.json588108.65129.923421,758
rails/articles/1.json89870.79184.343902,354
rails-int/articles328187.90467.842881,163
rails-int/articles/1321192.78298.772941,116
rails-int/articles/new462134.79338.663061,546
rails-int/articles.json583110.82132.063151,890
rails-int/articles/1.json93869.18163.013602,661
rails-jruby/articles1,12552.8479.921,0821,064
rails-jruby/articles/11,05256.0383.931,116965
rails-jruby/articles/new1,73833.2553.761,1761,512
rails-jruby/articles.json86971.9497.941,228724
rails-jruby/articles/1.json1,15452.0378.021,230960
Environment (captured 2026-07-25T14:32:25Z)

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

commit24f364217dff0fc3aced5c445054a3cdbf9d6751
branchmain
subjectfix: the `[]` reader's Cast was too wide — crystal NilAssertionError, rust move

Conditions at start

load average2.04 / 0.61 / 0.26
uptime16:32:25 up 43 days, 23:18, 2 users, load average: 2.04, 0.61, 0.26

Generated 2026-07-25T17:33:59Z from summary.json.