Roundhouse benchmark results

Run latest · captured 2026-09-23T04:00:52Z · 2e9507a98625 · 95 cells × 3 runs · 5 endpoints × 19 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 disabled, governor=performance, 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 (rails-jruby 60s, rails-truffleruby 240s, truffleruby 120s — those lanes plateau later); 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), and the TruffleRuby cells (truffleruby, rails-truffleruby) via --vm.Xmx — 512 MB for the emitted tree, 1 GB for stock Rails, which runs out of a 512 MB heap under TruffleRuby within a minute of load. 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.

rails-truffleruby is the stock-Rails-on-TruffleRuby baseline — the same Rails 8.1 fixture the rails lane serves, since TruffleRuby compiles the sqlite3 C extension and needs no JDBC adapter; truffleruby is the ruby lane's tree, unmodified, under the same interpreter. Both run the native standalone, single process, threads only (no fork).

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/seckotlin50,625rust47,767spinel39,570csharp-aot39,530csharp33,673jruby18,296swift17,565crystal17,414typescript10,173truffleruby8,416go7,738elixir5,996python4,703ruby4,449ruby-int3,169rails-truffleruby1,848rails-jruby954rails-int527rails523

/articles/1

1001,00010,000100,000req/secrust70,715kotlin59,740csharp-aot49,694spinel49,692csharp46,315crystal22,659jruby21,631swift19,222elixir12,145typescript11,945truffleruby11,494go8,062python5,345ruby4,586ruby-int3,417rails-truffleruby1,987rails-jruby943rails-int511rails503

/articles/new

1001,00010,000100,0001,000,000req/seckotlin125,767csharp-aot95,780rust94,571csharp78,211spinel70,926crystal44,534elixir41,432jruby39,342swift24,526typescript23,541truffleruby23,410go10,351python7,829ruby7,052ruby-int5,328rails-truffleruby3,804rails-jruby1,511rails-int736rails730

/articles.json

1001,00010,000100,000req/secrust78,035kotlin59,057csharp-aot49,695csharp48,073spinel41,746swift35,629crystal25,743jruby24,178go18,843elixir16,506typescript11,782truffleruby9,280python6,037ruby5,876ruby-int4,289rails-truffleruby3,417rails-jruby1,546rails-int975rails913

/articles/1.json

1001,00010,000100,0001,000,000req/secrust139,605kotlin95,970csharp-aot93,756csharp85,750spinel65,483crystal48,309swift42,359jruby37,884go34,598elixir26,290truffleruby22,176typescript18,663python8,500ruby7,938rails-truffleruby6,995ruby-int5,975rails-jruby2,567rails-int1,415rails1,376

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.

02,0004,0006,0008,00010,0004,449523/articles8.5×4,586503/articles/19.1×7,052730/articles/new9.7×5,876913/articles.json6.4×7,9381,376/articles/1.json5.8×rubyrails

The same comparison on the JVM: emitted jruby beats stock rails-jruby by 19.2× on /articles, against 8.5× for ruby over rails on CRuby — and the runtime swap alone is worth 1.8× to stock Rails, 4.1× to the emit.

The same comparison on TruffleRuby: emitted truffleruby beats stock rails-truffleruby by 4.6× on /articles, against 8.5× for ruby over rails on CRuby — and the runtime swap alone is worth 3.5× to stock Rails, 1.9× to the emit.

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.

02,0004,0006,0008,00010,0004,4493,169523527/articles4,5863,417503511/articles/17,0525,328730736/articles/new5,8764,289913975/articles.json7,9385,9751,3761,415/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/GBrust2,759,942crystal658,168spinel616,124csharp-aot554,807swift396,757csharp268,019go237,664kotlin97,596python59,673elixir49,672ruby36,584typescript32,720ruby-int29,212jruby19,393truffleruby10,944rails-int1,632rails1,527rails-truffleruby1,169rails-jruby880

/articles/1

1001,00010,000100,0001,000,00010,000,000req/sec/GBrust3,952,581crystal948,283spinel785,816csharp-aot676,210swift424,597csharp361,739go233,552elixir98,800kotlin94,364python67,760typescript38,235ruby37,244ruby-int30,927jruby20,908truffleruby14,826rails-int1,515rails1,403rails-truffleruby1,215rails-jruby816

/articles/new

1001,00010,000100,0001,000,00010,000,000req/sec/GBrust5,148,748crystal2,124,149csharp-aot1,291,110spinel1,057,265csharp611,310swift527,443elixir340,460go297,437kotlin185,049python99,250typescript75,190ruby54,825ruby-int46,090jruby37,758truffleruby29,979rails-truffleruby2,303rails-int2,150rails2,003rails-jruby1,302

/articles.json

1001,00010,000100,0001,000,00010,000,000req/sec/GBrust4,260,016crystal1,239,849swift770,063csharp-aot675,726spinel622,365go515,831csharp374,783elixir129,171kotlin77,007python76,517ruby45,881typescript38,473ruby-int37,539jruby21,489truffleruby11,847rails-int2,819rails2,497rails-truffleruby2,031rails-jruby1,201

/articles/1.json

1,00010,000100,0001,000,00010,000,000req/sec/GBrust7,566,018crystal2,292,968csharp-aot1,302,537spinel1,016,523go947,645swift915,053csharp665,908elixir209,031kotlin121,899python107,644typescript61,355ruby58,531ruby-int49,173jruby33,671truffleruby28,296rails-truffleruby4,148rails-int3,706rails3,316rails-jruby1,994

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.

elixir2.8×go2.4×swift2.0×rails-truffleruby1.8×rails1.7×rust1.6×rails-jruby1.6×crystal1.5×csharp1.4×jruby1.3×ruby1.3×python1.3×csharp-aot1.3×kotlin1.2×typescript1.2×truffleruby1.1×spinel1.1×

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 MBswift47 MBspinel68 MBcsharp-aot75 MBpython80 MBruby-int124 MBelixir130 MBcsharp131 MBruby138 MBtypescript320 MBrails-int391 MBrails425 MBtruffleruby802 MBkotlin806 MBjruby1,152 MBrails-jruby1,318 MBrails-truffleruby1,726 MB

The JVM cells run under a fixed -Xmx512m heap (the TruffleRuby cells under --vm.Xmx: 512 MB for the emit, 1 GB for stock Rails), 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.32.40.82.60.51.70.81.90.41.0
kotlin1.04.70.94.00.42.40.94.20.52.9
csharp1.45.51.14.90.54.11.15.40.53.7
csharp-aot1.44.31.13.80.52.41.13.40.52.2
spinel1.53.81.23.50.82.81.53.40.92.8
swift3.46.53.16.72.46.01.73.01.52.5
crystal3.64.52.73.31.41.82.43.01.31.7
jruby3.45.72.95.11.53.72.64.71.63.7
go7.820.67.617.63.150.13.18.51.67.0
elixir10.615.95.29.41.44.33.87.42.34.8
truffleruby6.819.85.114.02.87.66.515.22.88.0
typescript6.17.45.26.62.63.65.26.53.34.5
python13.613.712.012.68.28.710.610.87.58.0
ruby14.416.014.015.59.010.610.912.58.09.9
ruby-int20.222.118.721.312.013.714.917.110.712.5
rails-truffleruby31.057.028.750.714.930.717.032.88.218.7
rails-jruby62.593.863.288.538.459.138.059.822.038.7
rails-int117.4191.3121.5351.084.7145.464.787.943.068.5
rails118.2191.6123.1318.485.5141.570.891.043.7170.8

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

The 2 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 2 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
truffleruby/articles7,1658,4169,16910.0%
rails-truffleruby/articles1,7181,8481,8643.6%

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

Raw cell data (95 rows)
targetendpointreq/secp50 (ms)p99 (ms)RSS (MB)req/sec/GB
rust/articles47,7671.312.45172,759,942
rust/articles/170,7150.802.62183,952,581
rust/articles/new94,5710.521.71185,148,748
rust/articles.json78,0350.761.90184,260,016
rust/articles/1.json139,6050.381.05187,566,018
crystal/articles17,4143.594.4927658,168
crystal/articles/122,6592.723.3424948,283
crystal/articles/new44,5341.351.76212,124,149
crystal/articles.json25,7432.412.99211,239,849
crystal/articles/1.json48,3091.291.69212,292,968
go/articles7,7387.8420.5733237,664
go/articles/18,0627.5517.5735233,552
go/articles/new10,3513.1250.1235297,437
go/articles.json18,8433.148.4737515,831
go/articles/1.json34,5981.656.9637947,645
csharp-aot/articles39,5301.424.3372554,807
csharp-aot/articles/149,6941.123.8475676,210
csharp-aot/articles/new95,7800.482.36751,291,110
csharp-aot/articles.json49,6951.123.4075675,726
csharp-aot/articles/1.json93,7560.512.25731,302,537
kotlin/articles50,6251.024.6753197,596
kotlin/articles/159,7400.874.0264894,364
kotlin/articles/new125,7670.372.39695185,049
kotlin/articles.json59,0570.884.1578577,007
kotlin/articles/1.json95,9700.482.91806121,899
swift/articles17,5653.436.5145396,757
swift/articles/119,2223.076.6646424,597
swift/articles/new24,5262.355.9947527,443
swift/articles.json35,6291.683.0247770,063
swift/articles/1.json42,3591.492.5247915,053
csharp/articles33,6731.415.53128268,019
csharp/articles/146,3151.074.89131361,739
csharp/articles/new78,2110.514.12131611,310
csharp/articles.json48,0731.105.38131374,783
csharp/articles/1.json85,7500.513.73131665,908
spinel/articles39,5701.543.7565616,124
spinel/articles/149,6921.203.4764785,816
spinel/articles/new70,9260.762.76681,057,265
spinel/articles.json41,7461.483.3968622,365
spinel/articles/1.json65,4830.872.85651,016,523
elixir/articles5,99610.5515.9412349,672
elixir/articles/112,1455.159.3812598,800
elixir/articles/new41,4321.364.34124340,460
elixir/articles.json16,5063.807.44130129,171
elixir/articles/1.json26,2902.334.81128209,031
typescript/articles10,1736.127.3731832,720
typescript/articles/111,9455.236.5631938,235
typescript/articles/new23,5412.593.6132075,190
typescript/articles.json11,7825.246.5431338,473
typescript/articles/1.json18,6633.294.5031161,355
python/articles4,70313.5913.708059,673
python/articles/15,34511.9512.568067,760
python/articles/new7,8298.168.748099,250
python/articles.json6,03710.5910.778076,517
python/articles/1.json8,5007.468.0480107,644
ruby/articles4,44914.3916.0512436,584
ruby/articles/14,58613.9615.5012637,244
ruby/articles/new7,0529.0210.6113154,825
ruby/articles.json5,87610.8812.4913145,881
ruby/articles/1.json7,9388.009.8713858,531
ruby-int/articles3,16920.1922.1011129,212
ruby-int/articles/13,41718.7121.2611330,927
ruby-int/articles/new5,32811.9913.7311846,090
ruby-int/articles.json4,28914.8917.1411737,539
ruby-int/articles/1.json5,97510.6912.5212449,173
jruby/articles18,2963.405.6796619,393
jruby/articles/121,6312.865.131,05920,908
jruby/articles/new39,3421.543.711,06637,758
jruby/articles.json24,1782.574.671,15221,489
jruby/articles/1.json37,8841.613.741,15233,671
truffleruby/articles8,4166.8419.7678710,944
truffleruby/articles/111,4945.1413.9979314,826
truffleruby/articles/new23,4102.757.6179929,979
truffleruby/articles.json9,2806.4915.1980211,847
truffleruby/articles/1.json22,1762.818.0180228,296
rails/articles523118.21191.623511,527
rails/articles/1503123.06318.453671,403
rails/articles/new73085.53141.533732,003
rails/articles.json91370.8291.013742,497
rails/articles/1.json1,37643.66170.774253,316
rails-int/articles527117.38191.333311,632
rails-int/articles/1511121.48351.003451,515
rails-int/articles/new73684.67145.433502,150
rails-int/articles.json97564.7487.913542,819
rails-int/articles/1.json1,41543.0368.493913,706
rails-jruby/articles95462.4793.771,109880
rails-jruby/articles/194363.1988.491,183816
rails-jruby/articles/new1,51138.3959.101,1881,302
rails-jruby/articles.json1,54637.9959.841,3181,201
rails-jruby/articles/1.json2,56722.0338.741,3181,994
rails-truffleruby/articles1,84831.0556.971,6181,169
rails-truffleruby/articles/11,98728.7350.721,6741,215
rails-truffleruby/articles/new3,80414.9430.681,6912,303
rails-truffleruby/articles.json3,41717.0532.821,7222,031
rails-truffleruby/articles/1.json6,9958.2418.711,7264,148
Environment (captured 2026-09-23T04:00:52Z)

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=performance, boost disabled, 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.1.0 (4.0.0) 2026-07-22 aaa95d57e1 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+681 (a913fac7) [gcc 13.3.0 (cc)]
sqlite33.45.1 2024-01-30 16:01:20 e876e51a0ed5c5b3126f52e532044363a014bc594cfefa87ffb5b82257ccalt1 (64-bit)
trufflerubytruffleruby 40.0.0 (2026-09-17), like ruby 4.0.2, Oracle GraalVM Native [x86_64-linux]
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 rails-truffleruby ruby ruby-int jruby truffleruby 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, rails-truffleruby, ruby, ruby-int, jruby, truffleruby, python, spinel, typescript, elixir, go, csharp, csharp-aot, swift, kotlin, crystal, rust

Source

commit2e9507a98625166a1f549d90a01c3ebc21f80bc0
branchmain
subjectA failed compile in the spinel suite lane removes the binary it left

Conditions at start

load average2.02 / 0.69 / 0.27
uptime06:00:52 up 3 days, 19:16, 1 user, load average: 2.02, 0.69, 0.27

Generated 2026-09-23T09:05:19Z from summary.json.